textmachine/backend/internal/pipeline/bankstopparse.go

201 lines
8.2 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)
}
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
}