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 ``. 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 }