diff --git a/scripts/wake-digest.py b/scripts/wake-digest.py index 85b8d0b..4aea6b4 100755 --- a/scripts/wake-digest.py +++ b/scripts/wake-digest.py @@ -100,16 +100,50 @@ def extract_anchor(text, anchor, limit=900): paraphrases the steward's own words is the lossy extraction this whole architecture is designed to avoid. - Handles both wrap shapes: `**ANCHOR: content**` (label and content share the - bold run) and `**ANCHOR for next-Claude:** content` (label closes first).""" + Matching is CASE-INSENSITIVE. /wrap-up names these fields in prose ("The + pulling thread", "**Pulling thread:**") and has never mandated a case, so a + case-sensitive reader demands of the wrap a shape its own producer was never + told to emit. Censused 2026-08-23 by running this function over all 195 wrap + files: 86 threads and 84 questions sat in the body, correctly written, and + were unreadable for that reason alone — including the last three wraps. + + Shapes handled (the four the wraps actually emit): + **ANCHOR:** content label closes first + **ANCHOR: content** label and content share the bold run + **ANCHOR for next-Claude:** content label runs long + **ANCHOR** *(aside)*:\n\ncontent content in the NEXT paragraph + + The label ends at the first ':' ON THE ANCHOR'S OWN LINE. The old 40-char + window was a proxy for "same line"; when an aside pushed the colon past it the + function returned THE LABEL and the wake inherited a heading in place of the + question (2026-08-22). + + Where the anchor appears more than once, a match in LABEL POSITION wins over an + earlier narrative mention — case-insensitivity would otherwise let "…as the + pulling thread showed…" outrank the field it is describing. + """ text = strip_frontmatter(text) - i = text.find(anchor) - if i < 0: + cands = list(re.finditer(re.escape(anchor), text, re.I)) + if not cands: return None - s = text[i + len(anchor): i + len(anchor) + limit + 120] - c = s.find(":") - if 0 <= c <= 40: # remainder of the label, not content - s = s[c + 1:] + + def in_label_position(m): + bol = text.rfind("\n", 0, m.start()) + 1 + pre = re.sub(r"(?i)\b(the|a|our)\b", "", text[bol:m.start()]) + return pre.strip(" *#->\t") == "" + + m = next((c for c in cands if in_label_position(c)), cands[0]) + s = text[m.end():] + + line, nl, rest = s.partition("\n") + c = line.find(":") + if c >= 0: # remainder of the label, not content + line = line[c + 1:] + if not line.replace("*", "").strip(): # label ended the line — content is below + s = rest.lstrip("\n") + else: + s = line + nl + rest + cut = re.search(r"\n\s*\n", s) if cut: s = s[: cut.start()] @@ -895,6 +929,28 @@ def selftest(): extract_anchor("**A:** " + "w " * 200, "A", limit=50).endswith("…[truncated]")) chk("extract_anchor returns None when the anchor is absent", extract_anchor("no anchor here", "PULLING THREAD") is None) + # --- 2026-08-23 [FIX]: the three shapes the last three wraps emitted, none of + # which the case-sensitive/40-char version could read. Each earned from a real + # miss, not invented: 86 threads + 84 questions across 195 wraps. + chk("extract_anchor matches case-insensitively (**The pulling thread: …**)", + extract_anchor("**The pulling thread: the beacon, 12:00Z. Run ONCE.**\n\nnext", + "PULLING THREAD") + == "the beacon, 12:00Z. Run ONCE.") + chk("extract_anchor takes the NEXT paragraph when the label ends the line", + extract_anchor("**Literal question for next-Claude** *(checkable, and the " + "REVIEWED half is not my corpus)*:\n\nHas the rate risen?\n\nz", + "LITERAL QUESTION") + == "Has the rate risen?") + chk("extract_anchor prefers a LABEL over an earlier narrative mention" + " [negative control — case-insensitivity must not let prose outrank the field]", + extract_anchor("Yesterday the pulling thread went slack.\n\n" + "**Pulling thread:** the beacon.\n\nz", "PULLING THREAD") + == "the beacon.") + chk("extract_anchor still finds a narrative-only mention when no label exists" + " [positive control — proves the check above is not passing vacuously]", + extract_anchor("Yesterday the pulling thread: went slack.\n\nz", + "PULLING THREAD") + == "went slack.") MEMDIR = os.path.realpath(MEM) TOUCH = "~/_Dev/studium-engine/docs/the-chamber-touchstone.md" chk("classify_pointers accepts an in-directory relative pointer",