208 lines
8.6 KiB
Go
208 lines
8.6 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// bankstopparse.go: reading a stop table BACK into the rows that wrote it.
|
|
//
|
|
// ⛔ WHY THIS IS PRODUCTION CODE AND NOT A SCRIPT IN THE PROBE. The stop table is the richest record a
|
|
// PAID run leaves of what the bank role was given and what it answered — the drafts with their chunk
|
|
// counts, the frequency, the type, the confidence, the INVENTED mark, the contexts. Every later question
|
|
// about a purchase that has already happened is asked of this file, and a reader that re-derived its shape
|
|
// for itself would keep answering after the shape changed: the format would live in two places, one of
|
|
// which nobody edits. It lives beside renderBankStopTable, and a round-trip test holds the two together.
|
|
//
|
|
// It is DELIBERATELY STRICT. A line it does not recognise is an error, never a skip: the alternative is a
|
|
// reader that returns twelve rows out of sixty-nine and a caller that reports a share of twelve.
|
|
func ParseBankStopTable(raw []byte) ([]BankStopRow, error) {
|
|
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
|
|
var rows []BankStopRow
|
|
var cur *BankStopRow
|
|
header, declared := false, -1
|
|
flush := func() {
|
|
if cur != nil {
|
|
rows = append(rows, *cur)
|
|
cur = nil
|
|
}
|
|
}
|
|
for i := 0; i < len(lines); i++ {
|
|
text := lines[i]
|
|
line := i + 1
|
|
switch {
|
|
case !header:
|
|
// The banner and its legend: three prose lines whose only load-bearing part is the count.
|
|
if strings.HasPrefix(text, "BANK VERIFICATION TABLE — ") {
|
|
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(text, "BANK VERIFICATION TABLE — "), " term(s)"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("line %d: unreadable term count in the banner: %q", line, text)
|
|
}
|
|
declared, header = n, true
|
|
}
|
|
continue
|
|
case strings.TrimSpace(text) == "":
|
|
// ⛔ A BLANK LINE IS NOT ALWAYS A ROW BOUNDARY, and reading it as one made this reader refuse
|
|
// tables the engine itself writes. A source KWIC window carries the book's own newlines
|
|
// verbatim (AttachKWIC trims only the edges), and a window spanning a paragraph break carries
|
|
// the "\n\n" the chunker joins paragraphs with — which lands here as an EMPTY LINE INSIDE a
|
|
// context. One such term made the whole sheet unreadable and the measurement impossible.
|
|
//
|
|
// The boundary is decided by what comes NEXT: a blank followed by a row header opens a row; a
|
|
// blank followed by more context belongs to that context; a blank with nothing after it ends
|
|
// the table. Lookahead rather than a guess, because the format gives no other signal.
|
|
if nxt, ok := nextNonBlank(lines, i+1); cur != nil && len(cur.Contexts) > 0 && ok && !opensRow(nxt) {
|
|
cur.Contexts[len(cur.Contexts)-1] += "\n"
|
|
continue
|
|
}
|
|
flush()
|
|
continue
|
|
case opensRow(text):
|
|
flush()
|
|
src, dst, _ := strings.Cut(text, "\t")
|
|
cur = &BankStopRow{Src: src, Dst: undash(dst), Conf: -1}
|
|
continue
|
|
case cur == nil:
|
|
if strings.HasPrefix(text, "src · ") || strings.HasPrefix(text, "why ") || strings.HasPrefix(text, "existing rows)") {
|
|
continue // the legend's continuation lines
|
|
}
|
|
return nil, fmt.Errorf("line %d: a field line outside any term block: %q", line, text)
|
|
}
|
|
body := strings.TrimPrefix(text, " ")
|
|
switch {
|
|
case strings.HasPrefix(body, "origin="):
|
|
if err := parseStopFields(cur, body); err != nil {
|
|
return nil, fmt.Errorf("line %d: %w", line, err)
|
|
}
|
|
// ⚠ THE FOUR LIST FIELDS ARE KEPT WHOLE, and that is a correction rather than laziness. The writer
|
|
// joins each list with a separator its ITEMS may legitimately contain — the miner writes
|
|
// «related to mined 青山, 白山» as ONE evidence item, and the sheet joins evidence with ", " — so a
|
|
// reader that split them would hand its caller three items where the run had two, silently, with
|
|
// the row count still matching. Nothing downstream needs the items apart; what a caller asks is
|
|
// whether the field is EMPTY, and a whole line answers that without inventing anything.
|
|
case strings.HasPrefix(body, "why: "):
|
|
cur.Signals = whole(strings.TrimPrefix(body, "why: "))
|
|
case strings.HasPrefix(body, "CONTRADICTS this run's own: "):
|
|
cur.Contradicts = whole(strings.TrimPrefix(body, "CONTRADICTS this run's own: "))
|
|
case strings.HasPrefix(body, "THE BANK ALREADY HOLDS: "):
|
|
cur.BankHolds = whole(strings.TrimPrefix(body, "THE BANK ALREADY HOLDS: "))
|
|
case strings.HasPrefix(body, "evidence: "):
|
|
cur.Evidence = whole(strings.TrimPrefix(body, "evidence: "))
|
|
case strings.HasPrefix(body, "drafts: "):
|
|
// The drafts line is the exception, and it earns it: the caller needs the CHUNK COUNTS, every
|
|
// label is refused unless it parses, and a rendering containing " | " makes this line fail
|
|
// LOUDLY rather than silently splitting into two variants.
|
|
for _, label := range strings.Split(strings.TrimPrefix(body, "drafts: "), " | ") {
|
|
v, ok := ParseBankStopVariantLabel(label)
|
|
if !ok {
|
|
return nil, fmt.Errorf("line %d: unreadable draft label %q", line, label)
|
|
}
|
|
cur.Variants = append(cur.Variants, v)
|
|
}
|
|
case strings.HasPrefix(body, "ctx: "):
|
|
cur.Contexts = append(cur.Contexts, strings.TrimPrefix(body, "ctx: "))
|
|
default:
|
|
// A context window carries the source's own newlines, so its continuation lines are not
|
|
// prefixed. They belong to the context that opened them.
|
|
//
|
|
// ⚠ THE FORMAT'S ONE UNRESOLVABLE AMBIGUITY, named rather than papered over: a continuation
|
|
// line that itself begins « why: » (or any other field prefix) is read as a FIELD, and a
|
|
// continuation line carrying a tab is read as a new ROW. Neither is decidable from the text,
|
|
// and both are caught by the declared-count check below rather than by this switch.
|
|
if cur != nil && len(cur.Contexts) > 0 {
|
|
cur.Contexts[len(cur.Contexts)-1] += "\n" + text
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("line %d: unrecognised field line %q", line, text)
|
|
}
|
|
}
|
|
flush()
|
|
if !header {
|
|
return nil, fmt.Errorf("this is not a bank stop table: the banner line is missing")
|
|
}
|
|
// ⛔ THE COUNT THE FILE ITSELF DECLARES, checked against what was read. A parser that silently returns
|
|
// fewer rows than the document says it holds hands its caller a denominator that is quietly wrong, and
|
|
// every share computed from it is wrong by the same invisible amount.
|
|
if declared != len(rows) {
|
|
return nil, fmt.Errorf("the table declares %d term(s) and this reader recovered %d", declared, len(rows))
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// opensRow reports whether a line starts a new term block: an unindented `<src><TAB><dst>`.
|
|
func opensRow(s string) bool {
|
|
return s != "" && !strings.HasPrefix(s, " ") && strings.Contains(s, "\t")
|
|
}
|
|
|
|
// nextNonBlank returns the next line with content, and whether there is one.
|
|
func nextNonBlank(lines []string, from int) (string, bool) {
|
|
for i := from; i < len(lines); i++ {
|
|
if strings.TrimSpace(lines[i]) != "" {
|
|
return lines[i], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// whole keeps a rendered list line as ONE value — see the comment at its call sites.
|
|
func whole(s string) []string {
|
|
if s = strings.TrimSpace(s); s == "" {
|
|
return nil
|
|
}
|
|
return []string{s}
|
|
}
|
|
|
|
func parseStopFields(r *BankStopRow, body string) error {
|
|
rest := body
|
|
if i := strings.Index(rest, " INVENTED(no draft proposed it)"); i >= 0 {
|
|
r.Invented = true
|
|
rest = strings.Replace(rest, " INVENTED(no draft proposed it)", "", 1)
|
|
}
|
|
// ⚠ SETTLED EARLIER is read BEFORE NOT ASKED, and the order is load-bearing: both labels are trailing
|
|
// clauses, so cutting at the first one found would swallow the other and the round trip would lose a
|
|
// state the sheet shows. They are different facts and a reader must be able to tell them apart.
|
|
if i := strings.Index(rest, " SETTLED EARLIER("); i >= 0 {
|
|
r.SettledByBasis = true
|
|
rest = rest[:i]
|
|
}
|
|
if i := strings.Index(rest, " NOT ASKED("); i >= 0 {
|
|
r.SettledByBank = true
|
|
rest = rest[:i]
|
|
}
|
|
for _, f := range strings.Fields(rest) {
|
|
key, val, ok := strings.Cut(f, "=")
|
|
if !ok {
|
|
return fmt.Errorf("unreadable field %q", f)
|
|
}
|
|
var err error
|
|
switch key {
|
|
case "origin":
|
|
r.Origin = val
|
|
case "type":
|
|
r.Type = undash(val)
|
|
case "freq":
|
|
r.Freq, err = strconv.Atoi(val)
|
|
case "spread":
|
|
r.Spread, err = strconv.Atoi(val)
|
|
case "conventions":
|
|
r.Conventions, err = strconv.Atoi(val)
|
|
case "confidence":
|
|
r.Conf, err = strconv.Atoi(val)
|
|
default:
|
|
return fmt.Errorf("unknown field %q", key)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("field %q: %w", f, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// undash is dashIfEmpty's inverse: the renderer writes an em dash where a value was empty.
|
|
func undash(s string) string {
|
|
if s == "—" {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|