From 17f44144a5372ec80eefd318c31a8def35986b27 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 00:50:39 +0000 Subject: [PATCH 01/81] Show her working: static page of the lowering chain from the real lowerer and scorer Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + crates/weksa/examples/show_working.rs | 373 ++++++++++++++++++++++++++ scripts/publish-show-working.sh | 16 ++ tools/show-working/README.md | 9 + tools/show-working/build.py | 203 ++++++++++++++ 5 files changed, 602 insertions(+) create mode 100644 crates/weksa/examples/show_working.rs create mode 100755 scripts/publish-show-working.sh create mode 100644 tools/show-working/README.md create mode 100755 tools/show-working/build.py diff --git a/.gitignore b/.gitignore index 1428a57..e00572b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ build/ .venv/ venv/ target/ +tools/show-working/out/ diff --git a/crates/weksa/examples/show_working.rs b/crates/weksa/examples/show_working.rs new file mode 100644 index 0000000..cf24b64 --- /dev/null +++ b/crates/weksa/examples/show_working.rs @@ -0,0 +1,373 @@ +//! Build-time generator for the "Show her working" page. Runs the real +//! admission door, lowerer and IPA reader over the committed proof packet and +//! variants of it, and prints one JSON document on stdout: the typed slots, the +//! inventory entries the packet chose, and the outcome, which is lowered text or +//! a named refusal. Scoring is not here: `tools/show-working/build.py` calls the +//! `weksa score` CLI on the text this emits. +//! +//! Run from the repository root: `cargo run --release --example show_working`. +//! Every variant is a mutation of `proof_packet()`, spelled in its `source`. + +use std::path::Path; + +use serde_json::{Value, json}; +use weksa::packet::{ + EntityId, FeatValue, FeatureValue, Grade, NodeId, ProvenanceSource, RoleFill, proof_packet, +}; +use weksa::{ + CorpusStore, EntityStore, InvId, Inventory, InventoryStore, LowerError, Packet, PacketStore, + StoreError, lower, pronounce, +}; + +struct Fixture { + id: &'static str, + title: &'static str, + note: &'static str, + source: &'static str, + packet: Packet, +} + +fn id(s: &str) -> InvId { + s.parse().expect("inventory id") +} + +fn node(s: &str) -> NodeId { + NodeId::parse(s).expect("node id") +} + +fn grade(feature: &str, v: u16) -> FeatureValue { + FeatureValue { + feature: id(feature), + value: FeatValue::Grade(Grade::new(v).expect("grade")), + } +} + +fn fixtures() -> Vec { + let proof = proof_packet(); + let mut out = vec![Fixture { + id: "proof", + title: "Nibu warns the player off the control", + note: "The committed proof packet, unchanged.", + source: "proof_packet() in crates/weksa/src/packet.rs", + packet: proof.clone(), + }]; + let mut add = |id, title, note, source, f: &dyn Fn(&mut Packet)| { + let mut packet = proof.clone(); + f(&mut packet); + out.push(Fixture { id, title, note, source, packet }); + }; + add( + "no-addressee", + "Same meaning, nobody addressed", + "With the addressee unset the player is a third party: no \"you\", no reflexive.", + "proof_packet() with discourse.addressee = None", + &|p| p.discourse.addressee = None, + ); + add( + "stance-held-back", + "Anger held back, restraint high", + "Stance is the speaker's felt affect. The lowerer has no row that reads it, so the text is the proof's text.", + "proof_packet() with stance anger=300, restraint=700", + &|p| p.stance.features = vec![grade("feature:9", 300), grade("feature:18", 700)], + ); + add( + "hedged", + "Hedged and uncertain warning", + "Hedge and certainty are meaning the lowerer cannot yet say. It refuses rather than drop them.", + "proof_packet() with discourse hedge=600, certainty=400", + &|p| p.discourse.features = vec![grade("feature:6", 600), grade("feature:7", 400)], + ); + add( + "negated", + "Negated intervention (polarity: negative)", + "Dropping the negation would say the opposite, so the lowerer refuses. This is the shape of a negated Dao line awaiting polarity.", + "proof_packet() with predication n5 polarity=negative", + &|p| { + p.predications[1].features = vec![FeatureValue { + feature: id("feature:1"), + value: FeatValue::Option("negative".parse().expect("label")), + }] + }, + ); + add( + "consent-authored", + "Addressee consent, authored source", + "Someone who saw the request says the addressee asked for it. No lowering speaks for that request yet.", + "proof_packet() with discourse.addressee_consent = n4 (source Authored)", + &|p| p.discourse.addressee_consent = Some(node("n4")), + ); + add( + "consent-imported", + "Addressee consent, imported-corpus source", + "A reader of wording cannot have seen a request, so the packet is refused at admission (V15), before any lowering.", + "proof_packet() with addressee_consent = n4 and provenance.source = ImportedCorpus", + &|p| { + p.discourse.addressee_consent = Some(node("n4")); + p.provenance.source = ProvenanceSource::ImportedCorpus; + }, + ); + add( + "relation", + "A causal relation between the predications", + "Relations are not rendered; the lowerer refuses instead of asserting a different meaning.", + "proof_packet() with relation:8 (causes) n4 -> n5", + &|p| { + p.relations = vec![weksa::packet::Relation { + relation: id("relation:8"), + from: node("n4"), + to: node("n5"), + features: vec![], + }] + }, + ); + add( + "dangling-filler", + "A role filled by a node that does not exist", + "Refused at admission (V4): the packet is not a packet.", + "proof_packet() with predication n5 agent filler n9", + &|p| { + p.predications[1].roles[0] = RoleFill { role: id("role:1"), filler: node("n9") } + }, + ); + add( + "unknown-entity", + "A referent naming an entity nobody holds", + "The entity store is the only owner of names; a packet cannot invent one.", + "proof_packet() with referent n3 entity = entity:nobody", + &|p| p.referents[2].entity = Some(EntityId::parse("entity:nobody").expect("entity")), + ); + add( + "common-noun-agent", + "The ship mind as a common noun", + "No entity: the referent lowers as \"the ship mind\", and there is no name to pronounce.", + "proof_packet() with referent n3 entity = None", + &|p| p.referents[2].entity = None, + ); + out +} + +fn gloss(inv: &Inventory, rev: u32, i: &InvId) -> Value { + match inv.resolve(i, rev) { + Some(e) => json!({ "id": i.to_string(), "gloss": e.gloss }), + None => json!({ "id": i.to_string(), "gloss": null }), + } +} + +fn feature_json(inv: &Inventory, rev: u32, slot: &str, at: &str, f: &FeatureValue) -> Value { + let mut g = gloss(inv, rev, &f.feature); + g["slot"] = json!(slot); + g["at"] = json!(at); + g["value"] = serde_json::to_value(&f.value).expect("value"); + g +} + +/// Every feature the packet carries, with where it sits. +fn features(p: &Packet, inv: &Inventory) -> Vec { + let rev = p.inventory_rev; + let mut v = Vec::new(); + for f in &p.discourse.features { + v.push(feature_json(inv, rev, "discourse", "discourse", f)); + } + for f in &p.stance.features { + v.push(feature_json(inv, rev, "stance", "stance", f)); + } + for r in &p.referents { + for f in &r.features { + v.push(feature_json(inv, rev, "referent", r.id.as_str(), f)); + } + } + for pr in &p.predications { + for f in &pr.features { + v.push(feature_json(inv, rev, "predication", pr.id.as_str(), f)); + } + } + v +} + +/// Ids the packet chose, once each, with the inventory's gloss at its revision. +fn chosen(p: &Packet, inv: &Inventory) -> Vec { + let rev = p.inventory_rev; + let mut ids: Vec = Vec::new(); + let mut push = |i: &InvId| { + if !ids.contains(i) { + ids.push(*i) + } + }; + p.context.setting.iter().for_each(&mut push); + p.context.medium.iter().for_each(&mut push); + p.discourse.speech_act.iter().for_each(&mut push); + for r in &p.referents { + push(&r.kind); + push(&r.concept); + } + for pr in &p.predications { + push(&pr.concept); + push(&pr.frame); + pr.roles.iter().for_each(|f| push(&f.role)); + } + p.relations.iter().for_each(|r| push(&r.relation)); + features(p, inv) + .iter() + .filter_map(|f| f["id"].as_str().and_then(|s| s.parse().ok())) + .for_each(|i: InvId| push(&i)); + ids.iter().map(|i| gloss(inv, rev, i)).collect() +} + +fn slots(p: &Packet) -> Value { + let j = |v: &dyn erased::Ser| v.to_value(); + json!({ + "meaning": { + "referents": p.referents.iter().map(|r| j(r)).collect::>(), + "predications": p.predications.iter().map(|r| j(r)).collect::>(), + "relations": p.relations.iter().map(|r| j(r)).collect::>(), + }, + "stance": j(&p.stance), + "discourse": j(&p.discourse), + "context": j(&p.context), + "extensions": p.extensions.iter().map(|r| j(r)).collect::>(), + "constraints": j(&p.constraints), + "provenance": { "source": j(&p.provenance.source), "corpus_item": p.provenance.corpus_item.to_string(), "confidence": p.provenance.confidence.get() }, + "kind": j(&p.kind), + "inventory_rev": p.inventory_rev, + }) +} + +mod erased { + pub trait Ser { + fn to_value(&self) -> serde_json::Value; + } + impl Ser for T { + fn to_value(&self) -> serde_json::Value { + serde_json::to_value(self).expect("serialize") + } + } +} + +fn lower_error(e: &LowerError) -> Value { + let (variant, reason) = match e { + LowerError::Unrendered { what } => ("Unrendered", format!("the packet carries {what}, which the lowerer does not render")), + LowerError::NoRow { id } => ("NoRow", format!("no table row for {id}")), + LowerError::GlossMismatch { id, row } => ("GlossMismatch", format!("{id} is not the {row} the row speaks for")), + LowerError::UnknownRole { frame, role } => ("UnknownRole", format!("{frame} names role {role}, which the inventory lacks")), + LowerError::MissingRole { predication, role } => ("MissingRole", format!("{predication} fills no {role} role")), + LowerError::UnknownEntity { entity } => ("UnknownEntity", format!("the entity store does not hold {entity}")), + LowerError::NoForm { entity } => ("NoForm", format!("{entity} has no en-US form")), + LowerError::NoPronunciation { entity } => ("NoPronunciation", format!("{entity} has no ruled en-US pronunciation")), + LowerError::NoEntityReferent => ("NoEntityReferent", "the packet cites no entity".to_string()), + LowerError::EntityRead { entity, why } => ("EntityRead", format!("reading {entity}: {why}")), + }; + json!({ "variant": variant, "reason": reason, "debug": format!("{e:?}") }) +} + +struct World { + inventory: InventoryStore, + corpus: CorpusStore, + entities: EntityStore, + inv: Inventory, +} + +/// Admit `packet` through a fresh packet store and lower it. `Ok` carries the +/// text and the IPA outcome; `Err` is the refusal, as JSON. +fn run(w: &World, dir: &Path, packet: &Packet) -> Result<(String, Value), Value> { + let mut store = PacketStore::open(&dir.join("p.cc")).expect("packet store"); + let view = match store.admit(packet.clone(), &w.inventory, &w.corpus, &w.entities) { + Ok(v) => v, + Err(StoreError::Invalid(rs)) => { + return Err(json!({ + "kind": "admission_refused", + "refusals": rs.iter().map(|r| json!({ "rule": r.rule(), "detail": format!("{r:?}") })).collect::>(), + })); + } + Err(e) => { + return Err(json!({ "kind": "admission_refused", "refusals": [{ "rule": "store", "detail": format!("{e:?}") }] })); + } + }; + let text = lower(&view, &w.inv, &w.entities).map_err(|e| { + json!({ "kind": "lower_error", "error": lower_error(&e) }) + })?; + let ipa = match pronounce(&view, &w.entities) { + Ok(v) => json!({ "ok": v }), + Err(e) => json!({ "error": lower_error(&e) }), + }; + Ok((text, ipa)) +} + +fn main() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let inventory = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).expect("inventory"); + let inv = inventory.inventory().expect("inventory view"); + let w = World { + corpus: CorpusStore::open(&root.join("data/corpus/corpus.cc")).expect("corpus"), + entities: EntityStore::open(&root.join("data/entity/entity.cc")).expect("entities"), + inventory, + inv, + }; + let dir = std::env::temp_dir().join(format!("show-working-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + let mut out = Vec::new(); + for fx in fixtures() { + let p = &fx.packet; + let first = run(&w, &dir, p); + let outcome = match first { + Err(e) if e["kind"] == "admission_refused" => e, + first => { + // Ablation: run again with each feature removed, against the + // baseline outcome. It is how the page says whether the lowerer + // read a feature, instead of the page asserting it. + let base = first.as_ref().ok().map(|(t, _)| t.clone()); + let n_disc = p.discourse.features.len(); + let n_stance = p.stance.features.len(); + let total = features(p, &w.inv).len(); + let mut effects = Vec::new(); + for k in 0..total { + let mut q = p.clone(); + if k < n_disc { + q.discourse.features.remove(k); + } else if k < n_disc + n_stance { + q.stance.features.remove(k - n_disc); + } else { + let mut at = k - n_disc - n_stance; + 'o: { + for r in &mut q.referents { + if at < r.features.len() { r.features.remove(at); break 'o; } + at -= r.features.len(); + } + for pr in &mut q.predications { + if at < pr.features.len() { pr.features.remove(at); break 'o; } + at -= pr.features.len(); + } + } + } + let _ = std::fs::remove_file(dir.join("p.cc")); + effects.push(match (&base, run(&w, &dir, &q)) { + (Some(t), Ok((u, _))) if *t == u => "Without it the text is identical: the lowerer did not read it.", + (Some(_), Ok(_)) => "Without it the text changes: the lowerer read it.", + (Some(_), Err(_)) => "Without it the packet is refused.", + (None, Ok(_)) => "Without it the packet lowers: this feature is what the lowerer refuses.", + (None, Err(_)) => "Still refused without this feature alone.", + }); + } + match first { + Ok((text, ipa)) => json!({ "kind": "lowered", "text": text, "ipa": ipa, "feature_effects": effects }), + Err(e) => { let mut e = e; e["feature_effects"] = json!(effects); e } + } + } + }; + let _ = std::fs::remove_file(dir.join("p.cc")); + let mut slots = slots(p); + slots["features"] = json!(features(p, &w.inv)); + out.push(json!({ + "id": fx.id, + "title": fx.title, + "note": fx.note, + "source": fx.source, + "packet_id": p.id.to_string(), + "corpus_item": p.provenance.corpus_item.to_string(), + "slots": slots, + "chosen": chosen(p, &w.inv), + "outcome": outcome, + })); + } + let _ = std::fs::remove_dir_all(&dir); + println!("{}", serde_json::to_string_pretty(&json!({ "fixtures": out })).expect("json")); +} diff --git a/scripts/publish-show-working.sh b/scripts/publish-show-working.sh new file mode 100755 index 0000000..3af3194 --- /dev/null +++ b/scripts/publish-show-working.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build the "Show her working" page from the real lowerer and scorer, then publish it as a Mind preview. +# Run from the repository root, in a Mind's Self run (it writes only /srv/minds-web//). +# +# scripts/publish-show-working.sh [--no-publish] +# +# Needs cargo, python3 and an Ollama embedder at 127.0.0.1:11434 (qwen3-embedding:0.6b): the scorer is not stubbed. +# publish-mind-preview.sh lives in the gamecult-ops checkout: set GAMECULT_OPS to it (default ../gamecult-ops). +set -euo pipefail +[ -f Cargo.toml ] && [ -d tools/show-working ] || { echo "run from the Weksa repository root" >&2; exit 2; } +out=${SHOW_WORKING_OUT:-tools/show-working/out} +rm -rf "$out" +python3 -I tools/show-working/build.py "$out" +[ "${1:-}" = --no-publish ] && { echo "built $out"; exit 0; } +ops=${GAMECULT_OPS:-../gamecult-ops} +exec "$ops/scripts/publish-mind-preview.sh" "$out" weksa-show-working diff --git a/tools/show-working/README.md b/tools/show-working/README.md new file mode 100644 index 0000000..5e2a320 --- /dev/null +++ b/tools/show-working/README.md @@ -0,0 +1,9 @@ +# Show her working + +A static page that shows the lowering chain for committed fixture packets: typed slots, inventory entries chosen, lowered English, scorer closeness to the corpus target, with hedge, certainty and restraint annotated. Refusals (admission rules, `LowerError`, addressee consent, negation awaiting polarity) are results, not omissions. + +- `crates/weksa/examples/show_working.rs`: runs the real admission door, `lower` and `pronounce` over `proof_packet()` and its variants and prints JSON. Each feature is removed in turn and the packet re-run, so "the lowerer read this" is measured. +- `build.py`: runs that, scores each lowered text with the `weksa score` CLI (needs Ollama with `qwen3-embedding:0.6b`), writes `out/data.json` and one dependency-free `out/index.html`. Fails if anything cannot run; there is no fallback text. +- `scripts/publish-show-working.sh`: build, then publish through gamecult-ops `publish-mind-preview.sh`. + +Adding a fixture: add an entry in `fixtures()` in the example. A packet from another store needs a loader there; none exists yet. diff --git a/tools/show-working/build.py b/tools/show-working/build.py new file mode 100755 index 0000000..269032e --- /dev/null +++ b/tools/show-working/build.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Build the "Show her working" page from the real lowerer and scorer. + +usage: build.py [out_dir] (run from the repository root) + +1. `cargo run --example show_working` admits each fixture packet through the + packet store and lowers it with the English lowerer (crates/weksa/examples). +2. `weksa score` scores each lowered text against its corpus item (and the + other committed Nibu reference) through the Ollama embedder. +3. out_dir/data.json holds the joined result; out_dir/index.html is one file + with no scripts to fetch and no network calls. + +Nothing on the page is typed by hand except the layout and the reading notes. +If cargo or the scorer cannot run, the build fails: there is no fallback text. +Standard library only. +""" +import json +import pathlib +import re +import subprocess +import sys +import time +from html import escape as e + +root = pathlib.Path.cwd() +out_dir = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "tools/show-working/out") +weksa_bin = None + + +def run(cmd, **kw): + r = subprocess.run(cmd, cwd=root, capture_output=True, text=True, **kw) + if r.returncode != 0: + sys.exit(f"{' '.join(map(str, cmd))} failed:\n{r.stderr}") + return r.stdout + + +def cargo_target(): + meta = json.loads(run(["cargo", "metadata", "--format-version", "1", "--no-deps"])) + return pathlib.Path(meta["target_directory"]) + + +# -- 1. the real lowerer ------------------------------------------------------ +run(["cargo", "build", "--release", "--bin", "weksa", "--example", "show_working"]) +target = cargo_target() / "release" +weksa_bin = target / "weksa" +data = json.loads(run([str(target / "examples" / "show_working")])) + +# -- 2. the real scorer ------------------------------------------------------- +corpus = {} +for m in re.finditer(r'^(item:\S+) \[(.*?)\]\n "(.*)"$', run([str(weksa_bin), "corpus", "dump"]), re.M): + corpus[m.group(1)] = {"text": m.group(3), "meta": m.group(2)} +REFS_EXTRA = ["item:nibu:2"] +model = "qwen3-embedding:0.6b" + + +def score(text, items): + out = run([str(weksa_bin), "score", *items], input=text) + got = {} + for m in re.finditer(r"^(item:\S+) chrf=([\d.]+) meaning=(-?[\d.]+)", out, re.M): + got[m.group(1)] = {"chrf": float(m.group(2)), "meaning": float(m.group(3))} + if set(got) != set(items): + sys.exit(f"scorer answered {sorted(got)} for {items}:\n{out}") + return got + + +for fx in data["fixtures"]: + o = fx["outcome"] + if o["kind"] == "lowered": + refs = [fx["corpus_item"]] + [r for r in REFS_EXTRA if r != fx["corpus_item"]] + o["scores"] = [{"item": k, "target": k == fx["corpus_item"], **v} for k, v in score(o["text"], refs).items()] + +data["corpus"] = corpus +data["build"] = { + "commit": run(["git", "rev-parse", "HEAD"]).strip(), + "embedder": model, + "built_unix": int(time.time()), +} +out_dir.mkdir(parents=True, exist_ok=True) +(out_dir / "data.json").write_text(json.dumps(data, indent=1, ensure_ascii=False), encoding="utf-8") + +# -- 3. the page -------------------------------------------------------------- +CSS = """ +:root{--ink:#1c1b19;--mute:#5d5a54;--line:#cfc9bd;--bg:#f6f3ec;--card:#fff;--ok:#0b6b4b;--warn:#9a4a00;--bad:#9b1c2c;--bar:#365f9c} +*{box-sizing:border-box}body{font:16px/1.55 system-ui,sans-serif;max-width:62rem;margin:2rem auto;padding:0 1rem;color:var(--ink);background:var(--bg)} +h1{margin:0 0 .2rem}h2{margin:0 0 .4rem}h3{margin:1.2rem 0 .3rem;font-size:.85rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mute)} +code,.mono{font:13px/1.4 ui-monospace,Menlo,monospace}code{background:#ece8dd;padding:0 .25rem;border-radius:3px} +.lede{max-width:46rem}.meta{color:var(--mute);font-size:.88rem} +nav.pick{display:flex;flex-wrap:wrap;gap:.4rem;margin:1.2rem 0} +nav.pick a{border:1px solid var(--line);background:var(--card);padding:.25rem .6rem;border-radius:999px;text-decoration:none;color:var(--ink);font-size:.9rem} +nav.pick a.refused{border-color:var(--bad)}nav.pick a.lowered{border-color:var(--ok)} +article{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem 1.3rem;margin:1.4rem 0} +.chain{display:grid;grid-template-columns:repeat(4,1fr);gap:.6rem;margin:.6rem 0 1rem} +.chain div{border:1px solid var(--line);border-radius:6px;padding:.3rem .6rem;font-size:.82rem;color:var(--mute)} +.chain b{display:block;color:var(--ink)} +table{border-collapse:collapse;width:100%;font-size:.9rem}th,td{text-align:left;padding:.25rem .5rem;border-bottom:1px solid #e6e1d5;vertical-align:top}th{color:var(--mute);font-weight:600} +.out{font-size:1.15rem;padding:.6rem .9rem;border-left:4px solid var(--ok);background:#eef6f1} +.refusal{padding:.6rem .9rem;border-left:4px solid var(--bad);background:#faeef0}.refusal b{color:var(--bad)} +.tag{display:inline-block;font-size:.75rem;padding:0 .4rem;border-radius:3px;border:1px solid var(--line);color:var(--mute)} +.tag.read{border-color:var(--ok);color:var(--ok)}.tag.unread{border-color:var(--warn);color:var(--warn)}.tag.refuse{border-color:var(--bad);color:var(--bad)} +.bar{height:.7rem;background:#e4dfd2;border-radius:3px;min-width:8rem;position:relative}.bar i{display:block;height:100%;background:var(--bar);border-radius:3px} +.ref{font-size:.88rem;color:var(--mute);margin:.2rem 0 .6rem}.ref q{color:var(--ink)} +details{margin:.5rem 0}summary{cursor:pointer;color:var(--mute);font-size:.88rem} +section.gaps li{margin:.3rem 0} +@media(max-width:700px){.chain{grid-template-columns:1fr 1fr}} +""" + +PICK_JS = """(function(){var a=[].slice.call(document.querySelectorAll('article[data-fx]'));function show(){var h=location.hash.slice(1);var any=a.some(function(x){return x.id===h});a.forEach(function(x){x.hidden=any&&x.id!==h})} +window.addEventListener('hashchange',show);show()})()""" + + +def g(chosen, i): + return next((c["gloss"] for c in chosen if c["id"] == i), "?") + + +def val(v): + (k, x), = v.items() + return f"{x}‰" if k == "Grade" else str(x) + + +def slots_html(fx): + s, ch = fx["slots"], fx["chosen"] + rows = [] + for r in s["meaning"]["referents"]: + ent = f" · entity {e(r['entity'])}" if r["entity"] else "" + rows.append(f"{e(r['id'])}referent{e(g(ch, r['kind']))} / {e(g(ch, r['concept']))}{ent}") + for p in s["meaning"]["predications"]: + roles = ", ".join(f"{e(g(ch, x['role']))}={e(x['filler'])}" for x in p["roles"]) + rows.append(f"{e(p['id'])}predication{e(g(ch, p['concept']))} in frame {e(g(ch, p['frame']))}: {roles}") + for r in s["meaning"]["relations"]: + rows.append(f"relation{e(g(ch, r['relation']))}: {e(r['from'])} → {e(r['to'])}") + meaning = "" + "".join(rows) + "
nodekindcontent
" + d = s["discourse"] + drows = [] + if d["speech_act"]: + drows.append(f"speech act{e(g(ch, d['speech_act']))}") + if d["addressee"]: + drows.append(f"addressee{e(d['addressee'])}") + if d["addressee_consent"]: + drows.append(f"addressee consentpredication {e(d['addressee_consent'])} (what the addressee asked for)") + drows.append(f"source{e(s['provenance']['source'])}, kind {e(s['kind'])}") + return (f"

Slot: meaning

{meaning}

Slot: discourse

{''.join(drows)}
" + + stance_register(fx)) + + +def stance_register(fx): + s = fx["slots"] + eff = fx["outcome"].get("feature_effects") or [] + rows = [] + for i, f in enumerate(s["features"]): + note = eff[i] if i < len(eff) else "" + cls = "read" if "read it" in note else "unread" if "did not read" in note else "refuse" if "refus" in note else "" + rows.append(f"{e(f['gloss'])} {e(f['id'])}{e(f['slot'])} @ {e(f['at'])}{e(val(f['value']))}{e(note)}") + feats = ("" + "".join(rows) + "
featureslotvaluemeasured effect on the lowerer
") if rows else "

No features: no hedge, certainty, polarity, irony or stance set.

" + return ("

Slot: stance, hedge, certainty, restraint (features)

" + feats + + "

Register: the packet has no register slot. Register and address belong to a target-language profile, not to the packet; this lowerer reads none.

") + + +def outcome_html(fx): + o = fx["outcome"] + if o["kind"] == "lowered": + ipa = o["ipa"] + ipa_s = ("IPA of cited entities: " + e(" ".join(ipa["ok"])) + "") if "ok" in ipa else f"IPA refused: {e(ipa['error']['reason'])} {e(ipa['error']['variant'])}" + sc = [] + for s in o["scores"]: + ref = corpus[s["item"]]["text"] + sc.append(f"
{'Target' if s['target'] else 'Other reference'} {e(s['item'])}: {e(ref)}
" + f"" + f"
chrF {s['chrf']:.1f}
meaning cosine {s['meaning']:.3f}
") + return (f"

Lowered English

{e(o['text'])}

{ipa_s}

" + f"

Closeness to the corpus ({e(data['build']['embedder'])})

{''.join(sc)}") + if o["kind"] == "lower_error": + er = o["error"] + return f"

Refused by the lowerer

LowerError::{e(er['variant'])}: {e(er['reason'])}
{e(er['debug'])}

No text, so no score: nothing was invented to score.

" + items = "".join(f"
  • {e(r['rule'])} {e(r['detail'])}
  • " for r in o["refusals"]) + return f"

    Refused at admission

      {items}

    The packet never became a lowering input, so the lowerer never saw it.

    " + + +def card(fx): + o = fx["outcome"] + ch = "".join(f"{e(c['id'])}{e(c['gloss'] or '(unresolved)')}" for c in fx["chosen"]) + steps = [("1 Typed slots", "meaning, stance, discourse"), ("2 Inventory entries", f"{len(fx['chosen'])} ids, rev {fx['slots']['inventory_rev']}"), + ("3 Lowered English", {"lowered": "text", "lower_error": "refused", "admission_refused": "never reached"}[o["kind"]]), + ("4 Closeness", "scored" if o["kind"] == "lowered" else "no text, no score")] + chain = "".join(f"
    {e(a)}{e(b)}
    " for a, b in steps) + return (f"

    {e(fx['title'])}

    {e(fx['note'])}

    " + f"

    Packet {e(fx['packet_id'])} for {e(fx['corpus_item'])}. Made from: {e(fx['source'])}.

    " + f"
    {chain}
    {slots_html(fx)}" + f"
    Inventory entries chosen ({len(fx['chosen'])}){ch}
    idgloss at this revision
    " + f"{outcome_html(fx)}
    ") + + +nav = "".join(f"{e(f['title'])}" for f in data["fixtures"]) +b = data["build"] +page = f""" +Weksa: show her working +

    Show her working

    +

    A meaning packet goes in. The English lowerer picks inventory entries, writes a sentence, and a scorer measures how close that sentence is to a human-written target. Every number and sentence below was produced by running the real Rust lowerer and scorer when this page was built. The refusals are results too: when the lowerer cannot say a meaning without changing it, it says so instead of dropping the part it cannot say.

    +

    Built from {e(b['commit'][:10])}, embedder {e(b['embedder'])}. English only. Fixtures are the committed nibu-proof-001 packet and variants of it, each variant described in its own card. Scores are against the committed corpus: chrF (0-100) and embedding cosine. Raw data: data.json.

    + +{''.join(card(f) for f in data['fixtures'])} +""" +(out_dir / "index.html").write_text(page, encoding="utf-8") +print(f"wrote {out_dir}/index.html and data.json ({len(data['fixtures'])} fixtures)") From 1a20c977e0ff3f31416a21a3ad42d926ff152343 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 17:31:56 +0000 Subject: [PATCH 02/81] Pin own_family to offered items and loop open --answerer-family with tests Mixed-family corpus fixture (Anthropic, OpenAi, Unrecorded, PublicDomain, cast, a Test-group model item, a withdrawn item) and a CLI test reading the run record after `loop open`. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/tests/cli.rs | 39 +++++++++++ crates/weksa/tests/golf_loop.rs | 119 ++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 56a32af..4113a82 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -1431,6 +1431,45 @@ fn open_dao_run(d: &std::path::Path) { assert!(o.status.success(), "{}", out(&o)); } +/// The family `loop open` pinned at `root`, read from the run record. +fn pinned_family(d: &std::path::Path, root: &str, flag: &[&str]) -> weksa::corpus::ModelFamily { + let mut args = vec![ + "loop", + "open", + root, + "--label", + "fam", + "--answerer", + "fake-model", + "--lowerer-reach", + "tool-less-relay", + ]; + args.extend_from_slice(flag); + let o = weksa(d, &args); + assert!(o.status.success(), "{}", out(&o)); + weksa::run::RunStores::open(&d.join(root)) + .unwrap() + .record() + .answerer_family +} + +#[test] +fn loop_open_pins_answerer_family() { + use weksa::corpus::ModelFamily; + let d = tempfile::tempdir().unwrap(); + dao_stores(d.path()); + assert_eq!( + pinned_family(d.path(), "a", &["--answerer-family", "anthropic"]), + ModelFamily::Anthropic + ); + assert_eq!( + pinned_family(d.path(), "b", &["--answerer-family", "mistral"]), + ModelFamily::Mistral + ); + // Omitted, the family stays unrecorded and a round prints no own-family line. + assert_eq!(pinned_family(d.path(), "c", &[]), ModelFamily::Unrecorded); +} + #[test] fn loop_cycle_needs_a_floor() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index 60abc38..9d6d1ed 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -3365,3 +3365,122 @@ fn a_recall_mean_is_over_every_probe_an_unreadable_answer_counts_0_and_a_withdra let unnamed = m.seen.iter().filter(|t| t.contains("work: not published")).count(); assert_eq!((named, unnamed), (2, 1)); } + +/// Answers every prompt with text that is not a draft, so a round measures only +/// what it counts before admission. +struct NotADraft; + +impl weksa::model::ModelPort for NotADraft { + fn model(&self) -> &str { + "not-a-draft" + } + fn answer(&mut self, _: &weksa::context::Prompt) -> Result { + Ok("not a draft".to_string()) + } +} + +struct OneBag; + +impl weksa::score::Embedder for OneBag { + fn model(&self) -> &str { + "bag" + } + fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + Ok(texts.iter().map(|_| vec![1.0]).collect()) + } +} + +/// A corpus of every shape a family count must tell apart, and the own-family +/// count of a round over it by an answerer of `family`. +fn mixed_family_round(family: ModelFamily) -> u32 { + use common::{cast, group_in_bucket, item_in}; + use weksa::corpus::Source; + use weksa::run::{LowererReach, RoundOutcome, round}; + + let mut w = world(); + let written = |family| Source::ModelWritten { + family, + generator: None, + prompt_digest: None, + }; + let public_domain = Source::PublicDomain { + work: "w".into(), + author: "a".into(), + edition_url: "https://example.org/e".into(), + licence: "PD".into(), + jurisdiction: "US".into(), + translator: "t".into(), + edition_year: 1900, + }; + let (train, validation, test) = ( + group_in_bucket(10), + group_in_bucket(50), + group_in_bucket(90), + ); + let items = [ + // Offered, Anthropic: two of them count for Anthropic. + ("item:mix:1", &train, written(ModelFamily::Anthropic)), + ("item:mix:2", &validation, written(ModelFamily::Anthropic)), + // Offered, other families or no model at all: never Anthropic's. + ("item:mix:3", &train, written(ModelFamily::OpenAi)), + ("item:mix:4", &train, written(ModelFamily::Unrecorded)), + ("item:mix:5", &train, public_domain), + ("item:mix:6", &train, cast()), + // Model-written in a Test group: in no split, never offered. + ("item:mix:7", &test, written(ModelFamily::Anthropic)), + // Withdrawn after admission: never offered. + ("item:mix:8", &train, written(ModelFamily::Anthropic)), + ]; + let entities = common::entities_in(w.dir.path()); + for (key, group, source) in items { + let text = format!("Line {key}, distinct."); + w.corpus + .add(item_in(key, &text, group, source), &entities) + .unwrap(); + } + w.corpus.withdraw("item:mix:8").unwrap(); + let dir = w.dir.path().to_path_buf(); + for name in ["c.cc", "i.cc", "entities.cc"] { + assert!(dir.join(name).exists(), "{name}"); + } + let mut run = RunStores::create( + &dir.join("run"), + "mix", + RunPaths { + corpus: &dir.join("c.cc"), + inventory: &dir.join("i.cc"), + entities: &dir.join("entities.cc"), + }, + "not-a-draft", + "bag", + LowererReach::ToolLessRelay, + family, + ) + .unwrap(); + let scorer = weksa::score::Scorer::new(OneBag); + match round(&mut run, &mut NotADraft, &scorer).unwrap() { + RoundOutcome::Recorded(r) => { + // Seven items were offered: the world's own cast item and mix 1 to 6; + // the Test-group and withdrawn items were not. + assert_eq!( + r.parse_reasons().iter().map(|c| c.count).sum::(), + 7, + "offered items" + ); + r.own_family() + } + RoundOutcome::Unanswered { .. } => panic!("answered"), + } +} + +#[test] +fn own_family_counts_offered_items_of_the_answerer_family() { + // Two live Train/Validation Anthropic items; the Test-group and withdrawn + // Anthropic items are not offered, so they are not counted. + assert_eq!(mixed_family_round(ModelFamily::Anthropic), 2); + assert_eq!(mixed_family_round(ModelFamily::OpenAi), 1); + // Google wrote nothing here; an unrecorded answerer counts no item, though + // an offered item is itself unrecorded. + assert_eq!(mixed_family_round(ModelFamily::Google), 0); + assert_eq!(mixed_family_round(ModelFamily::Unrecorded), 0); +} From 99286d3892956089a5028d347b95e0f80f2c9d9d Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 17:33:53 +0000 Subject: [PATCH 03/81] DraftClass: split the data-message reader out and pin each class with a real serde error serde_json exposes no kind for a data error, so the prefix reader stays as DraftClass::of_data; the test feeds each class a real answer and an unknown message to Other. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/encode.rs | 60 +++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/crates/weksa/src/encode.rs b/crates/weksa/src/encode.rs index a71a0b9..3b9f437 100644 --- a/crates/weksa/src/encode.rs +++ b/crates/weksa/src/encode.rs @@ -94,24 +94,27 @@ impl DraftClass { Category::Syntax => DraftClass::Syntax, Category::Eof => DraftClass::Eof, Category::Io => DraftClass::Other, - // Only the leading words of serde's message are read, and only to - // pick one of these classes; the message is not kept. - Category::Data => { - let msg = e.to_string(); - if msg.starts_with("missing field") { - DraftClass::MissingField - } else if msg.starts_with("unknown field") { - DraftClass::UnknownField - } else if msg.starts_with("invalid type") { - DraftClass::InvalidType - } else if msg.starts_with("invalid value") { - DraftClass::InvalidValue - } else if msg.starts_with("not a ") { - DraftClass::BadForm - } else { - DraftClass::Other - } - } + Category::Data => Self::of_data(&e.to_string()), + } + } + + /// serde_json offers no kind for a data error: its message opens with serde's + /// own fixed words, or with a [`crate::form::FormError`]'s `not a `. Only + /// those words are read, to pick a class; the message is not kept, and any + /// other opening is [`DraftClass::Other`]. + fn of_data(msg: &str) -> Self { + if msg.starts_with("missing field") { + DraftClass::MissingField + } else if msg.starts_with("unknown field") { + DraftClass::UnknownField + } else if msg.starts_with("invalid type") { + DraftClass::InvalidType + } else if msg.starts_with("invalid value") { + DraftClass::InvalidValue + } else if msg.starts_with("not a ") { + DraftClass::BadForm + } else { + DraftClass::Other } } } @@ -319,15 +322,26 @@ mod tests { } #[test] - fn a_draft_error_is_classed_from_serdes_vocabulary_and_keeps_no_text() { + fn draft_class_per_real_serde_error() { + // Each class is fed by the error serde_json raises for a real answer. let class = |answer: &str| PacketDraft::parse(answer).unwrap_err().class; assert_eq!(class("not json"), DraftClass::Syntax); assert_eq!(class("{\"referents\": ["), DraftClass::Eof); assert_eq!(class("{}"), DraftClass::MissingField); - // The classes serde's own messages open with, read off a plain error. - let of = |r: Result| DraftClass::of(&r.unwrap_err()); - assert_eq!(of(serde_json::from_str("300")), DraftClass::InvalidValue); - assert_eq!(of(serde_json::from_str("\"x\"")), DraftClass::InvalidType); + assert_eq!(class("{\"bogus\": 1}"), DraftClass::UnknownField); + assert_eq!(class("{\"confidence\": \"x\"}"), DraftClass::InvalidType); + assert_eq!(class("{\"confidence\": -1}"), DraftClass::InvalidValue); + let bad_id = r#"{"referents": [{"id": "entity:x"}]}"#; + assert_eq!(class(bad_id), DraftClass::BadForm); + // A data error opening with words this reader does not know is Other. + assert_eq!( + DraftClass::of_data("something serde never said"), + DraftClass::Other + ); + assert_eq!(DraftClass::of_data(""), DraftClass::Other); + // None keeps a token of the answer. + let secret = r#"{"confidence": "canary-token"}"#; + assert!(!format!("{:?}", PacketDraft::parse(secret).unwrap_err()).contains("canary")); // Every code is distinct and none can hold answer text: they are fixed. let all = [ DraftClass::Syntax, From 99cf0cb39fd730891224d40870cb77814c86de61 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 17:33:53 +0000 Subject: [PATCH 04/81] Remove rustfmt diffs the parse-reason cut added to run.rs and tests Only hunks on lines that cut changed; import ordering and older code untouched. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/run.rs | 15 +++- crates/weksa/tests/common/mod.rs | 4 +- crates/weksa/tests/golf_loop.rs | 124 +++++++++++++++++++++----- crates/weksa/tests/original_corpus.rs | 8 +- 4 files changed, 119 insertions(+), 32 deletions(-) diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index 6d0195f..943894d 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -587,7 +587,16 @@ impl RunStores { reach: LowererReach, answerer_family: ModelFamily, ) -> Result { - Self::create_between(root, label, paths, model, embedder, reach, answerer_family, || {}) + Self::create_between( + root, + label, + paths, + model, + embedder, + reach, + answerer_family, + || {}, + ) } /// `create` with `between` run after the digests are taken and before the @@ -2125,7 +2134,9 @@ mod tests { entities: &entities, }, "fake-model", - "bag", crate::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + crate::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) .unwrap(); let mut model = fake(); diff --git a/crates/weksa/tests/common/mod.rs b/crates/weksa/tests/common/mod.rs index 7bd9338..73c8456 100644 --- a/crates/weksa/tests/common/mod.rs +++ b/crates/weksa/tests/common/mod.rs @@ -131,7 +131,9 @@ pub fn run_over(dir: &std::path::Path) -> weksa::run::RunStores { entities: &dir.join("entities.cc"), }, "fake-model", - "bag", weksa::run::LowererReach::ToolLessRelay, weksa::corpus::ModelFamily::Unrecorded, + "bag", + weksa::run::LowererReach::ToolLessRelay, + weksa::corpus::ModelFamily::Unrecorded, ) .unwrap() } diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index 9d6d1ed..0929e53 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -48,7 +48,9 @@ fn a_run_pins_its_stores_and_refuses_drift() { entities: &e, }, model, - "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) }; @@ -149,7 +151,9 @@ fn a_run_works_on_its_own_copy_of_the_inventory() { entities: &e, }, "fake-model", - "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) .unwrap(); @@ -224,7 +228,9 @@ fn create_refuses_a_root_that_holds_an_inventory_and_never_truncates_it() { entities: &dao.entities, }, "fake-model", - "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) }; @@ -269,7 +275,9 @@ fn a_create_that_fails_leaves_no_inventory_and_the_retry_succeeds() { entities, }, "fake-model", - "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) }; // A directory opens for reading and fails mid-copy, after the destination exists. @@ -382,14 +390,33 @@ fn a_store_changed_between_its_digest_and_its_open_is_drift() { entities: &e, }; assert_eq!( - RunStores::create_between(&root, "t", paths(), "fake-model", "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded, || more(&mut w)).err(), + RunStores::create_between( + &root, + "t", + paths(), + "fake-model", + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, + || more(&mut w) + ) + .err(), Some(RunError::Drift(DriftOf::Corpus)) ); assert!(!root.join("inventory.cc").exists()); // Not the copy and not the lock the store's open left beside it. assert!(!root.join("inventory.cc.lock").exists()); assert_eq!(RunStores::open(&root).err(), Some(RunError::NoRun)); - RunStores::create(&root, "t", paths(), "fake-model", "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded).unwrap(); + RunStores::create( + &root, + "t", + paths(), + "fake-model", + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, + ) + .unwrap(); } /// Runs `change` in the window between the pins and the open, at `open` and at @@ -412,9 +439,16 @@ fn window( inventory: &i, entities: &e, }; - let created = RunStores::create_between(&root, "t", paths, "fake-model", "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded, || { - change(&mut w, &root) - }) + let created = RunStores::create_between( + &root, + "t", + paths, + "fake-model", + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, + || change(&mut w, &root), + ) .err(); let left = root.join("inventory.cc").exists() || root.join("inventory.cc.lock").exists(); (opened, created, left, RunStores::open(&root).err()) @@ -829,7 +863,9 @@ fn dao_over(edit: impl FnOnce(&Path, &Path)) -> Dao { entities: &entities, }, "fake-model", - "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) .unwrap(); Dao { @@ -1478,17 +1514,41 @@ fn errors_echo_no_input() { inventory: &missing, entities: &missing, }; - let e = RunStores::create(&root, "ok", stores, "m", "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded) - .err() - .unwrap(); + let e = RunStores::create( + &root, + "ok", + stores, + "m", + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, + ) + .err() + .unwrap(); note(&e, &e); - let e = RunStores::create(&root, &format!("{canary}!"), stores, "m", "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded) - .err() - .unwrap(); + let e = RunStores::create( + &root, + &format!("{canary}!"), + stores, + "m", + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, + ) + .err() + .unwrap(); note(&e, &e); - let e = RunStores::create(&root, "ok", stores, &format!("{canary}\n"), "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded) - .err() - .unwrap(); + let e = RunStores::create( + &root, + "ok", + stores, + &format!("{canary}\n"), + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, + ) + .err() + .unwrap(); note(&e, &e); let w = world(); let run = run_over(w.dir.path()); @@ -1520,7 +1580,9 @@ fn errors_echo_no_input() { entities: &dao.entities, }, "fake-model", - "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) .unwrap(); let mut model = cycle_fake(&format!("{{\"{canary}\":1}}")); @@ -2450,7 +2512,9 @@ fn a_changed_head_is_drift() { entities: &dao.entities, }, "fake-model", - "bag", weksa::run::LowererReach::ToolLessRelay, ModelFamily::Unrecorded + "bag", + weksa::run::LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, ) .unwrap(); assert_eq!( @@ -2769,8 +2833,16 @@ fn a_summary_line_says_how_lowerings_were_answered() { inventory: &dao.inventory, entities: &dao.entities, }; - RunStores::create(&root, "b", paths, "fake-model", "bag", LowererReach::BriefedSubagent, ModelFamily::Unrecorded) - .unwrap(); + RunStores::create( + &root, + "b", + paths, + "fake-model", + "bag", + LowererReach::BriefedSubagent, + ModelFamily::Unrecorded, + ) + .unwrap(); let run = RunStores::open(&root).unwrap(); assert_eq!(run.record().lowerer_reach, LowererReach::BriefedSubagent); assert_eq!( @@ -3002,7 +3074,11 @@ fn parse_reasons_by_class() { for (code, answer) in cases { let (dao, r) = round_with_item_1(answer); assert_eq!(reasons(&r), [(code, 1)], "{code}"); - assert_eq!(r.train()[0].outcome, Outcome::Refused(Stage::Parse), "{code}"); + assert_eq!( + r.train()[0].outcome, + Outcome::Refused(Stage::Parse), + "{code}" + ); let line = dao.run.round_line(&r); assert!(line.contains(&format!("parse-reasons={code}x1")), "{line}"); } diff --git a/crates/weksa/tests/original_corpus.rs b/crates/weksa/tests/original_corpus.rs index cffd4f2..91b635b 100644 --- a/crates/weksa/tests/original_corpus.rs +++ b/crates/weksa/tests/original_corpus.rs @@ -99,7 +99,8 @@ fn the_recall_probe_covers_all_six_original_items_and_names_no_work() { }, "m", "bag", - weksa::run::LowererReach::BriefedSubagent, ModelFamily::Unrecorded + weksa::run::LowererReach::BriefedSubagent, + ModelFamily::Unrecorded, ) .unwrap(); let mut model = Unrelated(vec![]); @@ -121,10 +122,7 @@ impl weksa::model::ModelPort for Nope { fn model(&self) -> &str { "nope" } - fn answer( - &mut self, - _: &weksa::context::Prompt, - ) -> Result { + fn answer(&mut self, _: &weksa::context::Prompt) -> Result { Ok("not a draft".to_string()) } } From 1a7be123742fef8150d26380e4c81cd70b91abf7 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 17:34:43 +0000 Subject: [PATCH 05/81] Show-working page: lower_error covers main's four new LowerError variants; data.json publishes only fixture-cited corpus items The example's match lacked UnknownPhone, DanglingStress, ConsentMissing and AskerNotAddressee (E0004). build.py dumped every split into data.json, which would have published a Validation item; it now keeps the fixtures' cited items and fails if the written data.json holds any other item key. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/examples/show_working.rs | 4 ++++ tools/show-working/build.py | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/weksa/examples/show_working.rs b/crates/weksa/examples/show_working.rs index cf24b64..897bc13 100644 --- a/crates/weksa/examples/show_working.rs +++ b/crates/weksa/examples/show_working.rs @@ -254,6 +254,10 @@ fn lower_error(e: &LowerError) -> Value { LowerError::NoForm { entity } => ("NoForm", format!("{entity} has no en-US form")), LowerError::NoPronunciation { entity } => ("NoPronunciation", format!("{entity} has no ruled en-US pronunciation")), LowerError::NoEntityReferent => ("NoEntityReferent", "the packet cites no entity".to_string()), + LowerError::UnknownPhone { entity, at, .. } => ("UnknownPhone", format!("the ruled pronunciation of {entity} holds a symbol at {at} with no listed phone")), + LowerError::DanglingStress { entity, at } => ("DanglingStress", format!("the ruled pronunciation of {entity} has a stress mark at {at} with no vowel after it")), + LowerError::ConsentMissing { predication } => ("ConsentMissing", format!("{predication} is a request the packet does not carry the addressee's consent to")), + LowerError::AskerNotAddressee { predication } => ("AskerNotAddressee", format!("the agent of {predication} is not the addressee")), LowerError::EntityRead { entity, why } => ("EntityRead", format!("reading {entity}: {why}")), }; json!({ "variant": variant, "reason": reason, "debug": format!("{e:?}") }) diff --git a/tools/show-working/build.py b/tools/show-working/build.py index 269032e..a4c9bc5 100755 --- a/tools/show-working/build.py +++ b/tools/show-working/build.py @@ -46,10 +46,17 @@ weksa_bin = target / "weksa" data = json.loads(run([str(target / "examples" / "show_working")])) # -- 2. the real scorer ------------------------------------------------------- +REFS_EXTRA = ["item:nibu:2"] +# The page publishes only the corpus items its fixtures cite (plus the extra +# reference each lowered fixture is scored against). A Validation or Test item +# is never published: the dump holds every split. +cited = {fx["corpus_item"] for fx in data["fixtures"]} | set(REFS_EXTRA) corpus = {} for m in re.finditer(r'^(item:\S+) \[(.*?)\]\n "(.*)"$', run([str(weksa_bin), "corpus", "dump"]), re.M): - corpus[m.group(1)] = {"text": m.group(3), "meta": m.group(2)} -REFS_EXTRA = ["item:nibu:2"] + if m.group(1) in cited: + corpus[m.group(1)] = {"text": m.group(3), "meta": m.group(2)} +if set(corpus) != cited: + sys.exit(f"corpus dump lacks cited items {sorted(cited - set(corpus))}; unexpected {sorted(set(corpus) - cited)}") model = "qwen3-embedding:0.6b" @@ -77,6 +84,11 @@ data["build"] = { } out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "data.json").write_text(json.dumps(data, indent=1, ensure_ascii=False), encoding="utf-8") +# Guard, read back from the published file: no item key outside what a fixture cites. +published = json.loads((out_dir / "data.json").read_text(encoding="utf-8")) +stray = set(published["corpus"]) - cited +if stray or set(published["corpus"]) != cited: + sys.exit(f"data.json corpus {sorted(published['corpus'])} is not exactly the cited items {sorted(cited)}; stray: {sorted(stray)}") # -- 3. the page -------------------------------------------------------------- CSS = """ From 7ee9d73c729f3e56cd38e480911153e34cc2b323 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 17:34:44 +0000 Subject: [PATCH 06/81] Encode prompt: a referent's concept is never null Both parse refusals in the original-corpus round (runs/orig-3) were a null referents[].concept; the prompt gave 'or null' to entity, setting, medium and addressee and never said concept could not be. One sentence, pinned by a test. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/context.rs | 2 +- crates/weksa/tests/context.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index 5592e1e..1a648a0 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -59,7 +59,7 @@ Fields: - predications: a list of {id, concept, frame, roles, features}; roles is a list of {role, filler}, filler a node id. - relations: a list of {relation, from, to, features}. - stance: {features}; the speaker's felt affect and restraint, never the affect the line displays. -Node ids are n1, n2, and so on, one per referent and predication. Every other id comes from the inventory listed below and must be live at its revision. Write each id in full, exactly as the inventory lines spell it, family prefix included: concept:7, never 7. An entity comes only from the entities line. +Node ids are n1, n2, and so on, one per referent and predication. Every other id comes from the inventory listed below and must be live at its revision. Write each id in full, exactly as the inventory lines spell it, family prefix included: concept:7, never 7. An entity comes only from the entities line. A referent's concept is never null, even when it carries an entity: give the nearest concept id. A feature is {feature, value}; value is {\"Grade\": 0 to 1000}, {\"Option\": label} or {\"Flag\": true or false}, as the inventory lists the feature's kind."; /// The instructions a lowering prompt starts with: the lowerer's whole brief. diff --git a/crates/weksa/tests/context.rs b/crates/weksa/tests/context.rs index c379f0e..d6e2efe 100644 --- a/crates/weksa/tests/context.rs +++ b/crates/weksa/tests/context.rs @@ -694,6 +694,7 @@ mod lowering { #[test] fn the_encode_prompt_states_the_id_form_the_inventory_lines_use() { assert!(ENCODE.contains("family prefix included: concept:7, never 7")); + assert!(ENCODE.contains("A referent's concept is never null, even when it carries an entity: give the nearest concept id.")); let w = world(); let run = run_over(w.dir.path()); let inv = run.inventory().inventory().unwrap(); From 829559bbc3bb5be4234f0279fadbae23b6459ae1 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:03:26 +0000 Subject: [PATCH 07/81] Pin every DraftClass code string; feed Other real serde errors Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/encode.rs | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/weksa/src/encode.rs b/crates/weksa/src/encode.rs index 3b9f437..c9b245e 100644 --- a/crates/weksa/src/encode.rs +++ b/crates/weksa/src/encode.rs @@ -342,19 +342,25 @@ mod tests { // None keeps a token of the answer. let secret = r#"{"confidence": "canary-token"}"#; assert!(!format!("{:?}", PacketDraft::parse(secret).unwrap_err()).contains("canary")); - // Every code is distinct and none can hold answer text: they are fixed. - let all = [ - DraftClass::Syntax, - DraftClass::Eof, - DraftClass::MissingField, - DraftClass::UnknownField, - DraftClass::InvalidType, - DraftClass::InvalidValue, - DraftClass::BadForm, - DraftClass::Other, + // Other is fed by real serde errors the reader names no class for. + let dup = r#"{"confidence": 1, "confidence": 1}"#; + assert_eq!(class(dup), DraftClass::Other); + let variant = r#"{"stance": {"features": [{"feature": "feature:9", "value": {"Bogus": 1}}]}}"#; + assert_eq!(class(variant), DraftClass::Other); + // Each code is its literal, exactly: a round record keeps these strings. + let pinned = [ + (DraftClass::Syntax, "syntax"), + (DraftClass::Eof, "eof"), + (DraftClass::MissingField, "missing-field"), + (DraftClass::UnknownField, "unknown-field"), + (DraftClass::InvalidType, "invalid-type"), + (DraftClass::InvalidValue, "invalid-value"), + (DraftClass::BadForm, "bad-form"), + (DraftClass::Other, "other"), ]; - let codes: std::collections::BTreeSet<_> = all.iter().map(|c| c.code()).collect(); - assert_eq!(codes.len(), all.len()); + for (c, lit) in pinned { + assert_eq!(c.code(), lit); + } } #[test] From 76984ecd716d0488ea464810a22a38c73ea7fe39 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:03:47 +0000 Subject: [PATCH 08/81] show-working: refusal carries the closed reason only, no Debug of LowerError Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/examples/show_working.rs | 21 ++++++++++++++++++++- tools/show-working/build.py | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/weksa/examples/show_working.rs b/crates/weksa/examples/show_working.rs index 897bc13..dae9ca9 100644 --- a/crates/weksa/examples/show_working.rs +++ b/crates/weksa/examples/show_working.rs @@ -260,7 +260,7 @@ fn lower_error(e: &LowerError) -> Value { LowerError::AskerNotAddressee { predication } => ("AskerNotAddressee", format!("the agent of {predication} is not the addressee")), LowerError::EntityRead { entity, why } => ("EntityRead", format!("reading {entity}: {why}")), }; - json!({ "variant": variant, "reason": reason, "debug": format!("{e:?}") }) + json!({ "variant": variant, "reason": reason }) } struct World { @@ -375,3 +375,22 @@ fn main() { let _ = std::fs::remove_dir_all(&dir); println!("{}", serde_json::to_string_pretty(&json!({ "fixtures": out })).expect("json")); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refusal_carries_the_closed_reason_never_a_debug_of_the_error() { + // UnknownPhone holds the ruled IPA text; no field of the output may. + let e = LowerError::UnknownPhone { + entity: EntityId::parse("entity:nibu").expect("entity id"), + at: 3, + text: "CANARY-IPA".into(), + }; + let v = lower_error(&e); + assert!(!v.to_string().contains("CANARY")); + let keys: Vec<_> = v.as_object().expect("object").keys().cloned().collect(); + assert_eq!(keys, ["reason", "variant"]); + } +} diff --git a/tools/show-working/build.py b/tools/show-working/build.py index a4c9bc5..bc863f4 100755 --- a/tools/show-working/build.py +++ b/tools/show-working/build.py @@ -182,7 +182,7 @@ def outcome_html(fx): f"

    Closeness to the corpus ({e(data['build']['embedder'])})

    {''.join(sc)}") if o["kind"] == "lower_error": er = o["error"] - return f"

    Refused by the lowerer

    LowerError::{e(er['variant'])}: {e(er['reason'])}
    {e(er['debug'])}

    No text, so no score: nothing was invented to score.

    " + return f"

    Refused by the lowerer

    LowerError::{e(er['variant'])}: {e(er['reason'])}

    No text, so no score: nothing was invented to score.

    " items = "".join(f"
  • {e(r['rule'])} {e(r['detail'])}
  • " for r in o["refusals"]) return f"

    Refused at admission

      {items}

    The packet never became a lowering input, so the lowerer never saw it.

    " From 9f8ae13a121f7d98aa7228609e15e5bd4ef7bea6 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:04:37 +0000 Subject: [PATCH 09/81] original corpus: 22 new model-written items through the corpus door (Train 14, Validation 14) Items original:7..28, written for the non-memorised target; groups chosen so the V1 split derives Train or Validation. Test is not filled: a model-written item of a test group is in no split (split.rs), so none can be held out. Co-Authored-By: Claude Sonnet 5.5 --- data/original/corpus.cc | Bin 2050 -> 8614 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/data/original/corpus.cc b/data/original/corpus.cc index c11443faa710ffebcb55952272ae15eccff27fbf..57988dfaf5fe31057a344f805d956cfc1bcde77a 100644 GIT binary patch literal 8614 zcmd6sO>Y}T7{}>_9=O1P0}_W(D*74+d*i&+7ZkN11*sLHZ=$F|WAE5Ics*lg##vd2 z1K%J~DskwU1E{41^#fEW{SYH@;Q*+JcX2>m$}=--Z@ROQSDfaSIJ2A0FVFLT{{Lt9 zzv)d<)x*7ntVqQLS=njrefD)mh7zyzxEN352UL-f|H(t|3|#6T)7L*BHTf?yt8Fk^zQs@0!pX~X+YWKV6UcG(aJZ*;yI-m@vor))V z7&W8yMyu6mHLteXoz_ODxq0*8*E{7ab#n`SG@(SH5oIx=5~XxVkW@5HQHB)}h%$sx zOvo5@d6uqxPFUmGm3xFKs-~SGd``r@*`qYfk=;-4Ug9xHZwRUsVf$ZfF_j2DragVD zm*PoG8j>?cRAa5NhECrw{|0o0r2S6ECxiKdGuxRS3ml8_x6Jlrlybc8BUv>=4vNu-8}g5j(4ek?Ja}EJVlBbaABxR z;JFB*3c)XzD8^z~N$0`ruWIC11`B9WAcl#_BP6>Owecwg~H&U$0unFl_Uro z0Jb@=j2e8X$45j~^13_cg=G>ftEg@<{p_)d^tsEt%i}3PU`p^*+PX>z-T_Rw%Xf(V zpifB}S0egqj>w(~7Sd{6NWXcIR(10#G@BC`qX#2sdH}@{8I9G{0_tK3>ZCX*g5X&t zsY03(=-*&Lwd;cV-UHRvCk!Y-cc7~TkuX0}0EN*QD+)4=Bp#3jY=$&t!LnJa%jO@C z&6+;-PD(J05-f+dWb%4~GV0ItJR@oKSi3*(vJ}TL5rGie+kbZYzVNa)>DKEa^ZRaH zue?VjD0GZnlbQ0dJx5{YCQxH$#v=@FL8fG^Dl4lXC&z-aK_KLOOR!NF)DK>5ZRnF1 zIam^fQm{|P7<8eK=9+FlF{Y{#P$A%$?a**_X|pb$Cmx?oeflCdiVb9J4LMV38qm=) zWn{JdjVe!BxTXY)E}w+beSQs{ugmCPkI{L3_QqU>tlO4EsT53Q-!jV8D#KLCN=ii? z*;yD!pOqErl@sO2vdBHnBFp1r$IF=|(%Z$n%LpD4=8CL~+0djFCpk``=I+xS5uAfl zH;HDVCL^|>HSI_Z1gTecQSCJ28PKSy&uklq0Op*t0oe9D*+@vQFe%|D&~vf1@s(9q z^k$EmQ4y?nh-xRApUS-%we%TNXEVc)Ky@7wvsomy8M(5K3g>C6F4!_!)QR)V=c!i| z>9beBc~6%QYn8E8U=)n%e~2Nrb8uRdBMjCd^%4ZffiNno*YTIi{wk`SYdqT!t?HAP za%YtXKB?e)=(>!?w)cl~(O~E$ht~8@6 zaf*-GfQVDo^;JMpP>jJs`v37JT6b#&f(7u1X>1MrSP`p zw)czX_H&x21(4}CBOvUI=CggYtsbE(j}(qm|f_ZxjdN4 zE4_tTG%e?}o|b8~^#wx<<{lb=VC-VL1c$_}KGha%=kSYYcXJwzh2Ukwdrda$RX`zab{`HENG6&-=CunN!$c zAk9LodXyFb;l`l?Tr@~$01uv&`K{}7TPT^Z0Za(D_kwqD6DYfs!wWux6+_BqQyVTT zm8|fx_WDo})gGaLlo4&{C{Nkub(qWAGcrce^4Jaf*26-WmZhpPZWpBB?KKO>mX&0z z?)*IXy-aFTw=RHRg`DV2z8jO44`H!2thP_V&%(Om=;2s^vUdozq-rk=e<_1HuU|KM gpYJ4Q_SG%HEFrrCOEH)Xw*{;0ykaVv|r>&fwNp3G|uVH8R3W&r@P(F%G1 From 073b022c8a753d4103eea7d1c05cc43b9cfc606f Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:04:37 +0000 Subject: [PATCH 10/81] original_corpus tests: counts over 28 items, first six pinned, recall shows only live items Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/tests/original_corpus.rs | 88 ++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/crates/weksa/tests/original_corpus.rs b/crates/weksa/tests/original_corpus.rs index 91b635b..71cbf1a 100644 --- a/crates/weksa/tests/original_corpus.rs +++ b/crates/weksa/tests/original_corpus.rs @@ -25,7 +25,7 @@ fn stores() -> (CorpusStore, EntityStore) { fn every_original_item_records_its_author_and_none_is_public_domain() { let (corpus, entities) = stores(); let items = corpus.all().unwrap(); - assert_eq!(items.len(), 6); + assert_eq!(items.len(), 28); for item in &items { match &item.source { Source::ModelWritten { @@ -37,12 +37,18 @@ fn every_original_item_records_its_author_and_none_is_public_domain() { } other => panic!("{}: not model-written: {other:?}", item.id), } - assert!(entities.get(&item.speaker).unwrap().is_some(), "{}", item.id); + assert!( + entities.get(&item.speaker).unwrap().is_some(), + "{}", + item.id + ); } } #[test] fn the_original_items_spread_over_train_and_validation_and_none_is_in_test() { + // Model-written items cannot be in Test (`SplitScheme::split` excludes a + // model-written item of a test group), so this store holds none. let (corpus, _) = stores(); let splits: Vec> = corpus .all() @@ -51,10 +57,57 @@ fn the_original_items_spread_over_train_and_validation_and_none_is_in_test() { .map(|i| SplitScheme::V1.live(i)) .collect(); let count = |s| splits.iter().filter(|x| **x == Some(s)).count(); - assert_eq!((count(Split::Train), count(Split::Validation)), (3, 3)); + assert_eq!((count(Split::Train), count(Split::Validation)), (14, 14)); assert_eq!(count(Split::Test), 0); } +/// The first six items are the original golf-loop set; widening the store +/// adds items after them and never edits them. +#[test] +fn the_first_six_original_items_are_unchanged() { + let (corpus, _) = stores(); + let want = [ + ( + "item:original:1", + "A quiet mind is like still water in a deep bowl.", + "original-bowl", + ), + ( + "item:original:2", + "The river weakens the bank in spring and strengthens the roots in autumn.", + "original-river", + ), + ( + "item:original:3", + "The sage waits at the door until the water has settled.", + "original-door", + ), + ( + "item:original:4", + "The old gardener empties the jar and fills it again with rainwater.", + "original-jar", + ), + ( + "item:original:5", + "Under the same sky the earth keeps its patience, and the baker keeps hers.", + "original-sky", + ), + ( + "item:original:6", + "A hungry belly argues louder than any will.", + "original-belly", + ), + ]; + for (id, text, group) in want { + let item = corpus.get(id).unwrap().unwrap(); + assert_eq!( + (item.text.as_str(), item.group.as_str()), + (text, group), + "{id}" + ); + } +} + #[test] fn the_original_inventory_is_the_dao_revision_three() { let dao = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/daodejing/inventory.cc"); @@ -69,7 +122,7 @@ fn the_original_inventory_is_the_dao_revision_three() { /// The probe over the real original corpus: every item is offered, none names a /// work, and a model that answers with unrelated words reads at the noise floor. #[test] -fn the_recall_probe_covers_all_six_original_items_and_names_no_work() { +fn the_recall_probe_covers_every_live_original_item_and_names_no_work() { use weksa::model::{ModelPort, PortError}; use weksa::run::{RecallOutcome, RunPaths, RunStores}; struct Unrelated(Vec); @@ -107,9 +160,24 @@ fn the_recall_probe_covers_all_six_original_items_and_names_no_work() { let RecallOutcome::Read(r) = run.recall(&mut model).unwrap() else { panic!("answered"); }; - assert_eq!((r.n, r.answered), (6, 6)); + assert_eq!((r.n, r.answered), (28, 28)); assert!(r.chrf < 25.0, "{}", r.chrf); - assert_eq!(model.0.len(), 6); + assert_eq!(model.0.len(), 28); + // No item outside Train and Validation is shown to the model. + let all = CorpusStore::open(&c).unwrap().all().unwrap(); + for item in &all { + let live = SplitScheme::V1.live(item); + // A recall prompt shows the first half of the words. + let words: Vec<&str> = item.text.split_whitespace().collect(); + let opening = words[..words.len() / 2].join(" "); + let shown = model.0.iter().any(|t| t.contains(&opening)); + assert_eq!( + shown, + matches!(live, Some(Split::Train | Split::Validation)), + "{}", + item.id + ); + } assert!(model.0.iter().all(|t| t.contains("work: not published"))); assert!(model.0.iter().all(|t| !t.contains("translator:"))); } @@ -168,16 +236,16 @@ fn original_round(family: ModelFamily) -> (weksa::run::RunStores, weksa::run::Ro #[test] fn own_family_counts_and_labels() { - // The original corpus is six Anthropic-written items, all offered. + // The original corpus is Anthropic-written items, all offered. let (run, r) = original_round(ModelFamily::Anthropic); - assert_eq!(r.own_family(), 6); + assert_eq!(r.own_family(), 28); assert_eq!( r.parse_reasons().iter().map(|c| c.count).sum::(), - 6, + 28, "every item was refused at Parse and says why" ); let line = run.round_line(&r); - assert!(line.contains(" own-family 6"), "{line}"); + assert!(line.contains(" own-family 28"), "{line}"); // A different answerer family, or none recorded, labels nothing. for family in [ModelFamily::OpenAi, ModelFamily::Unrecorded] { From d4c72c54aaddbdde35f9368ab6cb596258135db6 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:04:40 +0000 Subject: [PATCH 11/81] show-working guard: reject Validation/Test items by split before writing Co-Authored-By: Claude Sonnet 5.5 --- tools/show-working/build.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/show-working/build.py b/tools/show-working/build.py index bc863f4..84e5189 100755 --- a/tools/show-working/build.py +++ b/tools/show-working/build.py @@ -49,7 +49,8 @@ data = json.loads(run([str(target / "examples" / "show_working")])) REFS_EXTRA = ["item:nibu:2"] # The page publishes only the corpus items its fixtures cite (plus the extra # reference each lowered fixture is scored against). A Validation or Test item -# is never published: the dump holds every split. +# is never published, even when hand-added to REFS_EXTRA or cited by a fixture: +# the dump holds every split, and the guard below reads each item's split. cited = {fx["corpus_item"] for fx in data["fixtures"]} | set(REFS_EXTRA) corpus = {} for m in re.finditer(r'^(item:\S+) \[(.*?)\]\n "(.*)"$', run([str(weksa_bin), "corpus", "dump"]), re.M): @@ -57,6 +58,10 @@ for m in re.finditer(r'^(item:\S+) \[(.*?)\]\n "(.*)"$', run([str(weksa_bin), " corpus[m.group(1)] = {"text": m.group(3), "meta": m.group(2)} if set(corpus) != cited: sys.exit(f"corpus dump lacks cited items {sorted(cited - set(corpus))}; unexpected {sorted(set(corpus) - cited)}") +# The dump's meta ends with the split (`... group=g Validation`, `... excluded`). +held_out = {k: v["meta"].split()[-1] for k, v in corpus.items() if v["meta"].split()[-1] in ("Validation", "Test")} +if held_out: + sys.exit(f"refusing to publish held-out items (nothing written): {held_out}") model = "qwen3-embedding:0.6b" @@ -84,11 +89,6 @@ data["build"] = { } out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "data.json").write_text(json.dumps(data, indent=1, ensure_ascii=False), encoding="utf-8") -# Guard, read back from the published file: no item key outside what a fixture cites. -published = json.loads((out_dir / "data.json").read_text(encoding="utf-8")) -stray = set(published["corpus"]) - cited -if stray or set(published["corpus"]) != cited: - sys.exit(f"data.json corpus {sorted(published['corpus'])} is not exactly the cited items {sorted(cited)}; stray: {sorted(stray)}") # -- 3. the page -------------------------------------------------------------- CSS = """ From 32368a998e114bfa93eb22a564735d3fb73f166d Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:09:47 +0000 Subject: [PATCH 12/81] original_corpus: recall probes only live Train/Validation over a fixture with withdrawn, model-written-Test and public-domain-Test items Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/tests/original_corpus.rs | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/weksa/tests/original_corpus.rs b/crates/weksa/tests/original_corpus.rs index 71cbf1a..79273f5 100644 --- a/crates/weksa/tests/original_corpus.rs +++ b/crates/weksa/tests/original_corpus.rs @@ -2,6 +2,8 @@ //! non-memorised golf-loop target. It holds no public-domain text, so a model //! cannot have it in pretraining, and every item says who wrote it. +mod common; + use std::path::Path; use weksa::corpus::{ModelFamily, Source}; @@ -254,3 +256,91 @@ fn own_family_counts_and_labels() { assert!(!run.round_line(&r).contains("own-family")); } } + +/// Over a fixture store whose items are not all live: recall probes the live +/// Train and Validation items and no others (a withdrawn item, a model-written +/// item of a test group, a public-domain Test item). +#[test] +fn recall_probes_only_live_train_and_validation_items() { + use common::{cast, entities_in, group_in_bucket, item_in, run_over}; + use weksa::model::{ModelPort, PortError}; + use weksa::run::RecallOutcome; + struct Record(Vec); + impl ModelPort for Record { + fn model(&self) -> &str { + "record" + } + fn answer(&mut self, p: &weksa::context::Prompt) -> Result { + self.0.push(p.text().to_string()); + Ok(r#"{"text":"nothing"}"#.to_string()) + } + } + let dir = tempfile::tempdir().unwrap(); + let entities = entities_in(dir.path()); + let mut store = CorpusStore::open(&dir.path().join("c.cc")).unwrap(); + let public = Source::PublicDomain { + work: "A Work".into(), + author: "An Author".into(), + edition_url: "https://example.org/e".into(), + licence: "CC0".into(), + jurisdiction: "US".into(), + translator: "A Translator".into(), + edition_year: 1900, + }; + let rows = [ + ( + "item:f:1", + "trainmark opens here and goes on", + 10, + cast(), + false, + ), + ( + "item:f:2", + "validmark opens here and goes on", + 50, + cast(), + false, + ), + ( + "item:f:3", + "gonemark opens here and goes on", + 10, + cast(), + true, + ), + ( + "item:f:4", + "modelmark opens here and goes on", + 90, + common::model(), + false, + ), + ( + "item:f:5", + "publicmark opens here and goes on", + 90, + public, + false, + ), + ]; + for (id, text, bucket, source, withdrawn) in rows { + let item = item_in(id, text, &group_in_bucket(bucket), source); + store.add(item, &entities).unwrap(); + if withdrawn { + store.withdraw(id).unwrap(); + } + } + drop(store); + let run = run_over(dir.path()); + let mut m = Record(vec![]); + let RecallOutcome::Read(r) = run.recall(&mut m).unwrap() else { + panic!("answered"); + }; + assert_eq!(r.n, 2); + let shown = |mark: &str| m.0.iter().any(|t| t.contains(mark)); + assert!(shown("trainmark") && shown("validmark")); + for hidden in ["gonemark", "modelmark", "publicmark"] { + assert!(!shown(hidden), "recall showed {hidden}"); + } +} From e7aef81309349a3f42739b6df8897e14b2c76819 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:24:37 +0000 Subject: [PATCH 13/81] data/original: 8 public-domain Test items with full provenance; Test held out of rounds and recall Eight one-sentence English excerpts from Dhammapada (Muller), Walden, Emerson, Bacon, Aesop (Townsend), Mill, Plato (Jowett), Darwin, each taken from the exact Project Gutenberg page named in edition_url and checked verbatim against the fetched text. Groups hash into Test (buckets 83-97); split.rs untouched, the ModelWritten-never-in-Test rule stands. Items 1..28 are byte-identical (digest pinned). Tests: counts Train/Validation 14/14, Test 8 all PublicDomain; no Test text in any round-1 or recall prompt or RoundRecord over data/original; a ModelWritten item in a Test group is read in no split. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/tests/original_corpus.rs | 217 ++++++++++++++++++++++++-- data/original/corpus.cc | Bin 8614 -> 13160 bytes data/original/entity.cc | 2 +- 3 files changed, 203 insertions(+), 16 deletions(-) diff --git a/crates/weksa/tests/original_corpus.rs b/crates/weksa/tests/original_corpus.rs index 79273f5..186cb2a 100644 --- a/crates/weksa/tests/original_corpus.rs +++ b/crates/weksa/tests/original_corpus.rs @@ -1,6 +1,7 @@ -//! The original corpus (`data/original`): passages written for the -//! non-memorised golf-loop target. It holds no public-domain text, so a model -//! cannot have it in pretraining, and every item says who wrote it. +//! The original corpus (`data/original`): 28 passages written for the +//! non-memorised golf-loop target (Train and Validation, all model-written) +//! and 8 public-domain Test items, each with its full provenance. Test is only +//! read at a checkpoint: no round or recall prompt over this store holds it. mod common; @@ -24,20 +25,58 @@ fn stores() -> (CorpusStore, EntityStore) { } #[test] -fn every_original_item_records_its_author_and_none_is_public_domain() { +fn every_original_item_records_its_author_and_the_public_domain_ones_their_provenance() { let (corpus, entities) = stores(); let items = corpus.all().unwrap(); - assert_eq!(items.len(), 28); + assert_eq!(items.len(), 36); + let (mut model, mut public) = (0, 0); for item in &items { match &item.source { Source::ModelWritten { family, generator, .. } => { + model += 1; + assert!(item.id.starts_with("item:original:"), "{}", item.id); assert_eq!(*family, ModelFamily::Anthropic, "{}", item.id); let generator = generator.as_deref().unwrap_or_default(); assert!(generator.contains("claude-sonnet-5-5"), "{}", item.id); } - other => panic!("{}: not model-written: {other:?}", item.id), + Source::PublicDomain { + work, + author, + edition_url, + licence, + jurisdiction, + translator, + edition_year, + } => { + public += 1; + assert!(item.id.starts_with("item:holdout:"), "{}", item.id); + // The edition is the page the text was taken from. + assert!( + edition_url.starts_with("https://www.gutenberg.org/cache/epub/") + && edition_url.ends_with(".txt"), + "{}", + item.id + ); + assert!(licence.contains("Public domain"), "{}", item.id); + assert!( + licence.contains("trademark or licence matter"), + "{}", + item.id + ); + assert!(jurisdiction.starts_with("United States"), "{}", item.id); + assert!( + [work, author, translator] + .iter() + .all(|s| !s.trim().is_empty()), + "{}", + item.id + ); + // Every edition and every author is out of copyright by date. + assert!((1600..=1900).contains(edition_year), "{}", item.id); + } + other => panic!("{}: not model-written or public-domain: {other:?}", item.id), } assert!( entities.get(&item.speaker).unwrap().is_some(), @@ -45,24 +84,67 @@ fn every_original_item_records_its_author_and_none_is_public_domain() { item.id ); } + assert_eq!((model, public), (28, 8)); } #[test] -fn the_original_items_spread_over_train_and_validation_and_none_is_in_test() { - // Model-written items cannot be in Test (`SplitScheme::split` excludes a - // model-written item of a test group), so this store holds none. +fn the_original_items_spread_over_train_and_validation_and_test_is_public_domain_only() { + // A model-written item of a Test group is in no split + // (`SplitScheme::split`), so Test holds the public-domain items alone. let (corpus, _) = stores(); - let splits: Vec> = corpus + let items = corpus.all().unwrap(); + let in_split = |s| { + items + .iter() + .filter(|i| SplitScheme::V1.live(i) == Some(s)) + .collect::>() + }; + assert_eq!(in_split(Split::Train).len(), 14); + assert_eq!(in_split(Split::Validation).len(), 14); + let test = in_split(Split::Test); + assert_eq!(test.len(), 8); + assert!( + test.iter() + .all(|i| matches!(i.source, Source::PublicDomain { .. })) + ); + // Train and Validation hold no public-domain item. + for s in [Split::Train, Split::Validation] { + assert!( + in_split(s) + .iter() + .all(|i| matches!(i.source, Source::ModelWritten { .. })) + ); + } +} + +/// The 28 model-written items are byte-for-byte what they were before Test +/// was added: id, text, group and source, folded in key order. +#[test] +fn the_28_model_written_items_are_unchanged() { + let (corpus, _) = stores(); + let mut items: Vec<_> = corpus .all() .unwrap() - .iter() - .map(|i| SplitScheme::V1.live(i)) + .into_iter() + .filter(|i| i.id.starts_with("item:original:")) .collect(); - let count = |s| splits.iter().filter(|x| **x == Some(s)).count(); - assert_eq!((count(Split::Train), count(Split::Validation)), (14, 14)); - assert_eq!(count(Split::Test), 0); + items.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(items.len(), 28); + let lines: Vec = items + .iter() + .map(|i| format!("{}\n{}\n{}\n{:?}", i.id, i.text, i.group, i.source)) + .collect(); + let parts: Vec<&[u8]> = lines.iter().map(|l| l.as_bytes()).collect(); + assert_eq!( + weksa::split::fnv1a64_of(&parts), + DIGEST_OF_THE_28, + "an original item changed" + ); } +/// Taken over the store at base da6bc7a, before any Test item existed. +const DIGEST_OF_THE_28: u64 = 3803723767212018190; + /// The first six items are the original golf-loop set; widening the store /// adds items after them and never edits them. #[test] @@ -344,3 +426,108 @@ fn recall_probes_only_live_train_and_validation_items() { assert!(!shown(hidden), "recall showed {hidden}"); } } + +/// Collects every prompt a round or recall shows the model; its answer is no +/// draft and no recall. +struct Record(Vec); + +impl weksa::model::ModelPort for Record { + fn model(&self) -> &str { + "nope" + } + fn answer(&mut self, p: &weksa::context::Prompt) -> Result { + self.0.push(p.text().to_string()); + Ok(r#"{"text":"nothing"}"#.to_string()) + } +} + +/// The public-domain Test items are held out: no prompt of round 1 over +/// `data/original`, no recall prompt, and no round record names one. +#[test] +fn no_test_item_is_offered_to_a_round_or_recall_or_recorded() { + use weksa::run::{LowererReach, RoundOutcome, RunPaths, RunStores, round}; + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/original"); + let tmp = tempfile::tempdir().unwrap(); + for f in ["corpus.cc", "entity.cc", "inventory.cc"] { + std::fs::copy(src.join(f), tmp.path().join(f)).unwrap(); + } + let p = |f: &str| tmp.path().join(f); + let mut run = RunStores::create( + &p("run"), + "orig", + RunPaths { + corpus: &p("corpus.cc"), + inventory: &p("inventory.cc"), + entities: &p("entity.cc"), + }, + "nope", + "bag", + LowererReach::ToolLessRelay, + ModelFamily::Unrecorded, + ) + .unwrap(); + let items = CorpusStore::open(&p("corpus.cc")).unwrap().all().unwrap(); + let held: Vec<_> = items + .iter() + .filter(|i| SplitScheme::V1.live(i) == Some(Split::Test)) + .collect(); + assert_eq!(held.len(), 8); + // A Test item's text, or any run of five of its words, in a prompt. + let leaks = |prompts: &[String], item: &weksa::corpus::CorpusItem| { + let w: Vec<&str> = item.text.split_whitespace().collect(); + prompts + .iter() + .any(|t| t.contains(&item.text) || w.windows(5).any(|k| t.contains(&k.join(" ")))) + }; + let scorer = weksa::score::Scorer::new(Bag); + let mut model = Record(vec![]); + let RoundOutcome::Recorded(r) = round(&mut run, &mut model, &scorer).unwrap() else { + panic!("answered"); + }; + // The check bites: the round did show live items, 28 of them. + assert!(model.0.len() >= 28, "{}", model.0.len()); + assert!( + items + .iter() + .filter(|i| matches!(SplitScheme::V1.live(i), Some(Split::Train))) + .any(|i| leaks(&model.0, i)), + "the probe cannot see a shown Train item" + ); + for item in &held { + assert!(!leaks(&model.0, item), "round prompt held {}", item.id); + assert!(r.calls().iter().all(|c| !c.items.contains(&item.id))); + assert!(r.train().iter().all(|row| row.item != item.id)); + } + let mut recall = Record(vec![]); + let weksa::run::RecallOutcome::Read(rr) = run.recall(&mut recall).unwrap() else { + panic!("answered"); + }; + assert_eq!((rr.n, rr.answered), (28, 28)); + for item in &held { + assert!(!leaks(&recall.0, item), "recall prompt held {}", item.id); + } +} + +/// The exclusion stands: a model-written item in a Test group is read in no +/// split, and is not moved to Train or Validation. +#[test] +fn a_model_written_item_in_a_test_group_is_read_in_no_split() { + use common::{group_in_bucket, item_in, model}; + for bucket in [80, 99] { + let item = item_in("item:f:1", "x opens", &group_in_bucket(bucket), model()); + assert_eq!(SplitScheme::V1.split(&item), None, "bucket {bucket}"); + assert_eq!(SplitScheme::V1.live(&item), None); + } + // The same group holds a public-domain item in Test. + let public = Source::PublicDomain { + work: "w".into(), + author: "a".into(), + edition_url: "https://example.org/e".into(), + licence: "l".into(), + jurisdiction: "US".into(), + translator: "none".into(), + edition_year: 1900, + }; + let item = item_in("item:f:2", "x opens", &group_in_bucket(80), public); + assert_eq!(SplitScheme::V1.live(&item), Some(Split::Test)); +} diff --git a/data/original/corpus.cc b/data/original/corpus.cc index 57988dfaf5fe31057a344f805d956cfc1bcde77a..d778901e62568a8a52f6777b0c158717ccaf0df6 100644 GIT binary patch literal 13160 zcmd5@&yU;28Ld+^d+4P-^w8VDX=87^+LgH4U9AkXSnqlrr*&-DPK=~YgONBA>7_W# z3~5CuilXOU93(&v0eUHNXpq=W;sQM+K+xJp{{aMY$)OEW^f!7adP(0mLrGpzHi-fT zc!;%<$kFi4``-7yZzivQ&`*N6&-k7|rQ;?MGtINCj0xb5glcER&PX1;MytwyVPxz*`9PPf&3@$O^4d@FZn`t#G*M96v* z7JGsXco6vEkjViX^H`{ct7N1Z&)!8cX1-U>JAEF}h-Y%mP1-13|xM|5|-pgpKDe-4-@~Y}G~=C0 zT+DqrZF_MX>F(OvcsyPmCb0;yoWoVAhHHj_Ya&W|YfiJZwH6KOWi@^^PT#ao#a#1P z>^KkmK0slycr`Y^9O{6Bx@>=yo#U^vbHEyqx|sZSv$=edT~P8W9-N(-{-U{+B^omv z`>_Y2U;(sw2=G)LFboD9Mc5Z?#A7V?Nj4BMSnRSnd(zHxA;{*kOEDM$E`%=b8i*5H zO?EX=zIJ_sHc-n=>Be-XfB1-|dT;54cjm)>^84#K5<5kzKY+wXawK-rAD=bE91HLd ztKso$f@x6F@|O7_o5)15Q20ZyCzU6qyFwQZeO%Z}N>^#e4%=0<`lXyNogSl6b<-57@4cAYpsHmY@(;x-UL_ zT7+uC_V_j5#iiyqQl}!BHVl_G43)-tDkU^yLzT{^^9ZT3U70G`*?CA9E~7MoFSS=!mib9@k#DR+!dr5 z!+>jDWP=z%AY}OdlnvSY##WvVw@+)$C%Vh_eWhb|Nhn|F4=?f{@)!|CveR~`stU5* zZquscrF7Xl^;q`o0qwG5PG!s9dg!vZt1kOvU&}3fJAGvb>lz-@EaY*bugjgJ#6J zIhTjzx=H!fk z%@J&WLf+)E9D}QPS~4k}&ZA^dw=#nYlltw#Ip16Q!9%rYvnqLxq&f0zrZ1m@6@w9n)UW}lXzoZ%LJvfwf+bN zWwSCJ==m%^F`*JYS=xOlCAKP4;d(lrh-14d+mDha)k~Y)ly2tc zdT;6cL#ffJOpVWT)aaxyJqwM{kw7`9ZDqrw2>JkSpfsH@9msJoSz%_^_o29;~lMia0_eU`F}$3_zn z1$ts_1}Yg54guQ?F9bFTjp_O=#OvJ|UR-;%#j3pJlwtMf0#;6X;#t{~?gSu&sPqCP zZ1BnxXgwj=dJ>H#`e5LTz+DK@jTuDdn%aU?-dxE*`da~{^|W~z7+8U!Ud{+X51=Fy zqX=y<0;tC|Wh4njP;$HwsktpwREQE)cIYA&&^qs1>ALo zstH1`y?!{qeEnN*tZ7m)jsC`k0vWk#niTMLFKMK^=%_S}Po$ z(i}M*DfBLCg5322P~~-32Gk!5+}cW?+>;D;*>v@c9DztPO*d>KQ?-ye+b!BQRt=rb9Ua`lCtxv)&Q>!t@=G#J)<~%aFuqGJg73$khypbzB%G}emWVw4} zDPOZV>DhU{8w-9Q(0n)H$HN1vP_5)xg_^lfdx??~PMs>6Bu++QNobmsS`$bM(&bdH zW(o$>X{O6($sy1-gK0l!nNZ{W0Wu}NLCm2=2phkUb@SN7rUBJLwFRkiMe}(+Hl0?w zOgx(!1`q7IPt2wzsfpT!JeoUCVqI+!DvQKUH&gh6dO1$|)Fn7?^pEb-Dto}gd~%i3 zb{KXoaNJ-aME9u|HpShVV3b#{rElhKgHyS#DcA;QJ$>?A=B%=U4-I%a5Q6F%d&PLA z4IMu1t{qN_bHH#2qrSzlQ@Nb^vJiIdbmy#1ex@Te7TDo1L1tMtHjSxl2gAxf`H0xV zu?uQX6MPG~HV;Sg`!%6jQPnFKJ4IFBNLMmLirz{@J45E+O3%1bvqfkWpNB)CjxFZb z9HbaztSw0Ye|h6<+O~q(BV#TO%aDPO6rDG-=XR_O8=&8Sk{a!WVF{bSym4)Ts$Aau zs}P4w18~@j+eWd8x4sEO0#HM)bu^jRIO7 z?80SjC?iUlv|F_@9@TsdTA9snocn{dup~6s+}9?hZ;VeZ!4iGcx@64jd3#@$AE);I z&cE~Sem&hH*Oifu&Kzdv>0F6TfCMK!Z4=&xDeZOIifM6~J=Zh3y*A9t6+>m&a)#EI zd9>Q;E}@0Ihhwq|2MN)hp^}=m`-n&6z-Zr&Oqnqje284)>746vz+1AGeu zbq*i~t}B3LiF69!oiFnEZKfx7aHu34B12e*fjzhcvaqk%wq2b+Rg(w!>_#Jw=9s(+Iy!q+HQ?PS9;(xjjb;|NlW)A6u4_v8ws= z&foJ$b<)-@{3_~1Yw~THH2B~Mm@%v-PT^s4Jx^Hw^!h2WmTABfZah g5>xwXn_vc^9`(R*));ooG~O3R-aISP<#j>ne^E|8k^lez delta 13 VcmaEnw#<3Lc}AJdlZ37*001ss1_1y7 diff --git a/data/original/entity.cc b/data/original/entity.cc index c92e33d..f2af96f 100644 --- a/data/original/entity.cc +++ b/data/original/entity.cc @@ -1 +1 @@ -“²cultcache.store.v1‘—¬weksa.entity¬weksa.entity¯weksa.entity.v1¬weksa.entityÙL{"schemaName":"weksa.entity","schemaVersion":"weksa.entity.v1","members":[]}‘¬weksa.entity�‘”­entity:keeper¬weksa.entity´2026-11-10T14:17:08ZÄ:“­entity:keeper‘“¥en-US‘ªthe keeperÀ�¤Live‘®weksa-original \ No newline at end of file +“²cultcache.store.v1‘—¬weksa.entity¬weksa.entity¯weksa.entity.v1¬weksa.entityÙL{"schemaName":"weksa.entity","schemaVersion":"weksa.entity.v1","members":[]}‘¬weksa.entity�™”­entity:keeper¬weksa.entity´2026-11-10T14:17:08ZÄ:“­entity:keeper‘“¥en-US‘ªthe keeperÀ�¤Live‘®weksa-original”¯entity:pd-aesop¬weksa.entity´2026-11-10T18:22:10ZÄM“¯entity:pd-aesop‘“¥en-US‘¥AesopÀ�¬PublicDomain‘¼Three Hundred Aesop's Fables”¯entity:pd-bacon¬weksa.entity´2026-11-10T18:22:10ZÄY“¯entity:pd-bacon‘“¥en-US‘¥BaconÀ�¬PublicDomain‘Ù'The Essays or Counsels, Civil and Moral”°entity:pd-darwin¬weksa.entity´2026-11-10T18:22:10ZÄK“°entity:pd-darwin‘“¥en-US‘¦DarwinÀ�¬PublicDomain‘¸On the Origin of Species”´entity:pd-dhammapada¬weksa.entity´2026-11-10T18:22:10ZĆ“´entity:pd-dhammapada‘“¥en-US‘®the DhammapadaÀ�¬PublicDomain‘ÙFDhammapada, a Collection of Verses (Sacred Books of the East, vol. 10)”±entity:pd-emerson¬weksa.entity´2026-11-10T18:22:10ZÄI“±entity:pd-emerson‘“¥en-US‘§EmersonÀ�¬PublicDomain‘´Essays: First Series”®entity:pd-mill¬weksa.entity´2026-11-10T18:22:10ZÄ9“®entity:pd-mill‘“¥en-US‘¤MillÀ�¬PublicDomain‘ªOn Liberty”¯entity:pd-plato¬weksa.entity´2026-11-10T18:22:10ZÄ=“¯entity:pd-plato‘“¥en-US‘¥PlatoÀ�¬PublicDomain‘¬The Republic”±entity:pd-thoreau¬weksa.entity´2026-11-10T18:22:10ZÄc“±entity:pd-thoreau‘“¥en-US‘§ThoreauÀ�¬PublicDomain‘Ù-Walden, and On The Duty Of Civil Disobedience \ No newline at end of file From e0e3c93d979ab94f51429f779b8654dd564fdeb0 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:26:31 +0000 Subject: [PATCH 14/81] Stance ablation: replay, drop and permute a packet's stance beside recall RecordedAnswers is the one reader of .answer files (AnswerFiles reads through it). RunStores::stance_ablation asks a fresh lowerer the round's own lowering prompt again (replay), with the stance emptied (drop) and with the next item's stance in its split (permute), for live Train and Validation packets only, and reads how far the lowering moves and what the item's own stance adds to fidelity. A diagnostic in /ablation-: records nothing, never writes round-. `weksa loop ablate --round n`; exits 3 until answered. Touches no pinned prompt-source file. The source tripwire now allows the ablation as a second Prompt::lower call site. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/main.rs | 50 +++- crates/weksa/src/model.rs | 40 ++- crates/weksa/src/run.rs | 290 +++++++++++++++++++- crates/weksa/tests/cli.rs | 71 +++++ crates/weksa/tests/golf_loop.rs | 452 +++++++++++++++++++++++++++++++- 5 files changed, 892 insertions(+), 11 deletions(-) diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index 5b88c14..a92c010 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -11,11 +11,14 @@ use weksa::corpus::{CorpusItem, ModelFamily, NameSpan, Source, Turn}; use weksa::entity::{Entity, EntityOrigin, LanguageForms, Pronunciation}; use weksa::inventory::{proof_revision, seed}; use weksa::packet::proof_packet; -use weksa::run::{CycleOutcome, LowererReach, RecallOutcome, RoundOutcome, RunPaths, RunStores, cycle, round}; +use weksa::model::RecordedAnswers; +use weksa::run::{ + AblationOutcome, CycleOutcome, LowererReach, RecallOutcome, RoundOutcome, RunPaths, RunStores, cycle, round, +}; use weksa::score::ScoreInput; use weksa::{ AnswerFiles, CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, PacketStore, - Scorer, SplitScheme, StoreError, lower, pronounce, validate, + Scorer, Split, SplitScheme, StoreError, lower, pronounce, validate, }; #[derive(Parser)] @@ -112,6 +115,19 @@ enum LoopCommand { /// half). Prompts and answers live in `/recall`; the command exits 3 /// until every prompt has an answer. A diagnostic, never a gate. Recall { root: PathBuf }, + /// Ask whether the lowerer uses a packet's stance: for each live Train and + /// Validation packet of round ROUND, ask again with the stance as it is, + /// emptied, and swapped for the next item's, and read how far the lowering + /// moves from the round's own answer. Prompts and answers live in + /// `/ablation-`; the command exits 3 until every prompt has an + /// answer. A diagnostic, never a gate; Test is never touched. + Ablate { + root: PathBuf, + #[arg(long)] + round: u32, + #[arg(long, default_value = "http://127.0.0.1:11434")] + ollama_url: String, + }, /// Run one edit-accept cycle of the run at ROOT. The stores are read only /// from the run's record. The editor's prompt, then the candidate round's /// encode prompts, then its lowering prompts are written in turn; the @@ -785,6 +801,36 @@ fn main() -> Result<()> { RoundOutcome::Recorded(r) => println!("{}", run.round_line(&r)), } } + Command::Loop(LoopCommand::Ablate { + root, + round, + ollama_url, + }) => { + let run = RunStores::open(&root)?; + let model = run.record().model.clone(); + let mut recorded = + RecordedAnswers::new(root.join(format!("round-{round}")), model.clone()); + let mut files = AnswerFiles::new(run.ablation_dir(round), model); + let scorer = Scorer::new(OllamaEmbedder::new(&ollama_url, &run.record().embedder)); + match run.stance_ablation(round, &mut recorded, &mut files, &scorer)? { + AblationOutcome::Unanswered { prompts } => { + for digest in &prompts { + println!("unanswered {digest}"); + } + println!("unanswered {} prompts", prompts.len()); + std::process::exit(3); + } + AblationOutcome::Read(readings) => { + for (split, reading) in readings { + let name = match split { + Split::Train => "train", + _ => "validation", + }; + println!("{name} {}", reading.line()); + } + } + } + } Command::Loop(LoopCommand::Recall { root }) => { let run = RunStores::open(&root)?; let mut files = AnswerFiles::new(run.recall_dir(), run.record().model.clone()); diff --git a/crates/weksa/src/model.rs b/crates/weksa/src/model.rs index 2f83176..1a79449 100644 --- a/crates/weksa/src/model.rs +++ b/crates/weksa/src/model.rs @@ -48,6 +48,37 @@ impl AnswerFiles { } } +/// Answers read from one directory and nothing else: `.answer` is the +/// reply to the prompt whose digest is ``. A prompt with no file is +/// [`PortError::Unanswered`]; this port never creates or writes anything. The +/// one reader of answer files: [`AnswerFiles`] reads through it. +pub struct RecordedAnswers { + dir: PathBuf, + model: String, +} + +impl RecordedAnswers { + pub fn new(dir: PathBuf, model: String) -> Self { + Self { dir, model } + } +} + +impl ModelPort for RecordedAnswers { + fn model(&self) -> &str { + &self.model + } + + fn answer(&mut self, prompt: &Prompt) -> Result { + match std::fs::read(self.dir.join(format!("{}.answer", prompt.digest()))) { + Ok(bytes) => { + String::from_utf8(bytes).map_err(|_| PortError::Io(ErrorKind::InvalidData)) + } + Err(e) if e.kind() == ErrorKind::NotFound => Err(PortError::Unanswered), + Err(e) => Err(PortError::Io(e.kind())), + } + } +} + impl ModelPort for AnswerFiles { fn model(&self) -> &str { &self.model @@ -56,12 +87,9 @@ impl ModelPort for AnswerFiles { fn answer(&mut self, prompt: &Prompt) -> Result { let io = |e: std::io::Error| PortError::Io(e.kind()); let digest = prompt.digest(); - match std::fs::read(self.dir.join(format!("{digest}.answer"))) { - Ok(bytes) => { - return String::from_utf8(bytes).map_err(|_| PortError::Io(ErrorKind::InvalidData)); - } - Err(e) if e.kind() == ErrorKind::NotFound => {} - Err(e) => return Err(io(e)), + match RecordedAnswers::new(self.dir.clone(), self.model.clone()).answer(prompt) { + Err(PortError::Unanswered) => {} + read => return read, } std::fs::create_dir_all(&self.dir).map_err(io)?; let path = self.dir.join(format!("{digest}.prompt")); diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index 943894d..958edf1 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -22,6 +22,9 @@ //! `ModelPort` (`Prompt::lower`), in two answer phases; the CLI's `lower` still //! uses the template table. The prompt source pin covers `context.rs`, //! `split.rs` and `lower.rs`. +//! +//! `RunStores::stance_ablation`, like `recall`, is a diagnostic in its own +//! directory: it records nothing and never writes its round's directory. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -39,6 +42,7 @@ use crate::corpus::{CorpusItem, CorpusItemId, ModelFamily, Source}; use crate::encode::{EditDraft, LoweringDraft, PacketDraft, encoder_id}; use crate::form::{is_attestation, is_slug}; use crate::model::{ModelPort, PortError}; +use crate::packet::{Packet, Stance}; use crate::score::{CHRF_VERSION, chrf, Embedder, ItemScore, ScoreInput, Scorer, SplitMean, aggregate}; use crate::split::{Split, SplitScheme, fnv1a64, fnv1a64_of, hex16}; use crate::store::{ @@ -950,6 +954,290 @@ impl RunStores { } } +/// What the lowerer's answer did to each variant of a packet's stance, per +/// split. Means only; no key, text or stance value. +#[derive(Clone, Debug, PartialEq)] +pub struct AblationReading { + /// Items whose recorded and replayed lowerings were both usable. + pub n: u32, + /// Answers dropped as unusable, replay or variant. + pub unusable: u32, + /// Mean agreement of the replay with the recorded lowering: the noise + /// floor a variant is read against. + pub replay: Agreement, + pub drop: VariantReading, + pub permute: VariantReading, +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Agreement { + pub chrf: f64, + pub meaning: f64, +} + +/// One variant's reading: agreement with the recorded lowering, pairs whose +/// chrF fell below the replay's, and the mean fidelity the item's own stance +/// adds (`score(item, replay) - score(item, variant)`). +#[derive(Clone, Debug, PartialEq, Default)] +pub struct VariantReading { + pub n: u32, + pub chrf: f64, + pub meaning: f64, + pub moved: u32, + pub ref_gain_chrf: f64, + pub ref_gain_meaning: f64, +} + +impl AblationReading { + pub fn line(&self) -> String { + let variant = |name: &str, v: &VariantReading| { + format!( + "{name} n={} chrf={:.2} meaning={:.3} moved={} ref_gain_chrf={:.2} ref_gain_meaning={:.3}", + v.n, v.chrf, v.meaning, v.moved, v.ref_gain_chrf, v.ref_gain_meaning + ) + }; + format!( + "ablation n={} unusable={} replay chrf={:.2} meaning={:.3} | {} | {}", + self.n, + self.unusable, + self.replay.chrf, + self.replay.meaning, + variant("drop", &self.drop), + variant("permute", &self.permute), + ) + } +} + +#[derive(Debug)] +pub enum AblationOutcome { + Unanswered { prompts: Vec }, + /// Train then Validation. + Read(Vec<(Split, AblationReading)>), +} + +/// What the port made of one lowering prompt. +enum Asked { + Text(String), + Unusable, + Waiting, +} + +fn asked(port: &mut dyn ModelPort, prompt: &Prompt) -> Result { + match port.answer(prompt) { + Ok(a) => Ok(LoweringDraft::parse(&a) + .ok() + .filter(LoweringDraft::usable) + .map_or(Asked::Unusable, |d| Asked::Text(d.text))), + Err(PortError::Io(ErrorKind::InvalidData)) => Ok(Asked::Unusable), + Err(PortError::Unanswered) => Ok(Asked::Waiting), + Err(PortError::Io(kind)) => Err(RunError::Io(kind)), + } +} + +fn mean(xs: impl Iterator) -> f64 { + let (sum, n) = xs.fold((0.0, 0u32), |(s, n), x| (s + x, n + 1)); + if n == 0 { 0.0 } else { sum / f64::from(n) } +} + +type Scored = (ItemScore, ItemScore); + +/// One item's reference text, recorded lowering, replay, and drop and permute +/// lowerings. +type Lowered = (String, String, String, [Option; 2]); + +impl RunStores { + /// `/ablation-`, where a stance ablation's prompts, answers and + /// variant packet stores live. + pub fn ablation_dir(&self, n: u32) -> PathBuf { + self.root.join(format!("ablation-{n}")) + } + + /// A diagnostic like [`recall`](Self::recall), never a gate: for each live + /// Train and Validation packet of plain round `n`, asks the lowerer again + /// (`model`, the replay), with the packet's stance emptied (drop) and with + /// the next item's stance in its split (permute), and reads how far the + /// lowering moves against the round's own answer (`recorded`). Test and + /// withdrawn items are never asked. Records nothing and writes nothing in + /// `round-`; the variant stores are rebuilt each call. An answer that + /// is not a usable lowering drops its pair and is counted in `unusable`. + pub fn stance_ablation( + &self, + n: u32, + recorded: &mut dyn ModelPort, + model: &mut dyn ModelPort, + scorer: &Scorer, + ) -> Result { + let store = |_| RunError::Store; + check_drift(self, model, scorer)?; + self.rounds() + .map_err(store)? + .into_iter() + .find(|r| r.n() == n && r.cycle().is_none()) + .ok_or(RunError::Malformed("round"))?; + let inventory = self.inventory.inventory().map_err(store)?; + + let dir = self.ablation_dir(n); + std::fs::create_dir_all(&dir).map_err(|e| RunError::Io(e.kind()))?; + for name in ["base", "drop", "permute"] { + for suffix in ["cc", "cc.lock"] { + let _ = std::fs::remove_file(dir.join(format!("{name}.{suffix}"))); + } + } + let mut stores = [ + PacketStore::open(&dir.join("base.cc")).map_err(store)?, + PacketStore::open(&dir.join("drop.cc")).map_err(store)?, + PacketStore::open(&dir.join("permute.cc")).map_err(store)?, + ]; + + // Live Train and Validation packets, in item key order within a split. + let round_packets = PacketStore::open(&self.root.join(format!("round-{n}")).join("packets.cc")) + .map_err(store)? + .all() + .map_err(store)?; + let mut splits: [(Split, Vec<(CorpusItem, Packet)>); 2] = + [(Split::Train, Vec::new()), (Split::Validation, Vec::new())]; + for packet in round_packets { + let item = self + .corpus + .get(packet.provenance.corpus_item.as_str()) + .map_err(store)? + .ok_or(RunError::Store)?; + for (split, held) in &mut splits { + if SplitScheme::V1.live(&item) == Some(*split) { + held.push((item.clone(), packet.clone())); + } + } + } + + let mut waiting: Vec = Vec::new(); + let mut readings = Vec::new(); + // Texts per split: (item text, recorded, replay, [drop, permute]). + let mut texts: Vec> = Vec::new(); + let mut unusable = [0u32; 2]; + for (at, (_, held)) in splits.iter_mut().enumerate() { + held.sort_by(|a, b| a.0.id.as_str().cmp(b.0.id.as_str())); + let mut rows = Vec::new(); + for (i, (item, packet)) in held.iter().enumerate() { + let packet_item = packet.provenance.corpus_item.clone(); + let mut prompt_of = |slot: usize, packet: Packet| -> Result { + let view = stores[slot] + .admit(packet, &self.inventory, &self.corpus, &self.entities) + .map_err(|_| RunError::Store)?; + Prompt::lower(&view, &packet_item, &inventory, &self.entities) + .map_err(|_| RunError::Store) + }; + let base = prompt_of(0, packet.clone())?; + let mut variants: [Option; 2] = [None, None]; + if !packet.stance.features.is_empty() { + let mut p = packet.clone(); + p.stance = Stance::default(); + variants[0] = Some(prompt_of(1, p)?); + } + if held.len() > 1 { + let mut p = packet.clone(); + p.stance = held[(i + 1) % held.len()].1.stance.clone(); + let prompt = prompt_of(2, p)?; + if prompt.digest() != base.digest() { + variants[1] = Some(prompt); + } + } + // The round's own answer must exist; a missing one is not a gap to ask for. + let recorded_text = match asked(recorded, &base)? { + Asked::Waiting => return Err(RunError::Store), + other => other, + }; + let replay = asked(model, &base)?; + let mut got: [Option; 2] = [None, None]; + for (slot, prompt) in got.iter_mut().zip(&variants) { + let Some(prompt) = prompt else { continue }; + match asked(model, prompt)? { + Asked::Text(t) => *slot = Some(t), + Asked::Unusable => unusable[at] += 1, + Asked::Waiting => { + let d = prompt.digest(); + if !waiting.contains(&d) { + waiting.push(d); + } + } + } + } + match (recorded_text, replay) { + (Asked::Text(b), Asked::Text(r)) => { + rows.push((item.text.clone(), b, r, got)); + } + (_, Asked::Waiting) => { + let d = base.digest(); + if !waiting.contains(&d) { + waiting.push(d); + } + } + _ => unusable[at] += 1, + } + } + texts.push(rows); + } + if !waiting.is_empty() { + return Ok(AblationOutcome::Unanswered { prompts: waiting }); + } + + // One score call: for each item, the replay then each variant, each + // against the recorded lowering (agreement) and the item (fidelity). + let mut inputs: Vec = Vec::new(); + for (reference, base, replay, got) in texts.iter().flatten() { + for output in std::iter::once(replay).chain(got.iter().flatten()) { + inputs.push(ScoreInput { reference: base, output }); + inputs.push(ScoreInput { reference, output }); + } + } + let mut scores = if inputs.is_empty() { + Vec::new() + } else { + scorer.score(&inputs).map_err(|_| RunError::Embed)? + } + .into_iter(); + let mut pair = || -> Result { + Ok((scores.next().ok_or(RunError::Store)?, scores.next().ok_or(RunError::Store)?)) + }; + for (at, ((split, _), rows)) in splits.iter().zip(&texts).enumerate() { + let mut replay: Vec = Vec::new(); + let mut variants: [Vec<(Scored, Scored)>; 2] = [Vec::new(), Vec::new()]; + for (_, _, _, got) in rows { + let r = pair()?; + for (k, g) in got.iter().enumerate() { + if g.is_some() { + variants[k].push((r.clone(), pair()?)); + } + } + replay.push(r); + } + let variant = |v: &[(Scored, Scored)]| VariantReading { + n: v.len() as u32, + chrf: mean(v.iter().map(|(_, x)| x.0.chrf)), + meaning: mean(v.iter().filter_map(|(_, x)| x.0.meaning)), + moved: v.iter().filter(|(r, x)| x.0.chrf < r.0.chrf).count() as u32, + ref_gain_chrf: mean(v.iter().map(|(r, x)| r.1.chrf - x.1.chrf)), + ref_gain_meaning: mean( + v.iter().filter_map(|(r, x)| Some(r.1.meaning? - x.1.meaning?)), + ), + }; + readings.push(( + *split, + AblationReading { + n: rows.len() as u32, + unusable: unusable[at], + replay: Agreement { + chrf: mean(replay.iter().map(|r| r.0.chrf)), + meaning: mean(replay.iter().filter_map(|r| r.0.meaning)), + }, + drop: variant(&variants[0]), + permute: variant(&variants[1]), + }, + )); + } + Ok(AblationOutcome::Read(readings)) + } +} + /// The distinct rule codes an admission refusal broke: validator rules by /// code, a store refusal by its variant name. Nothing from the packet or the /// stores is kept; an I/O failure is `io`. @@ -1008,7 +1296,7 @@ pub fn round( } /// The model and embedder the run pinned are the ones answering. -fn check_drift( +fn check_drift( run: &RunStores, model: &M, scorer: &Scorer, diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 4113a82..3d38bba 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -1633,3 +1633,74 @@ fn loop_open_requires_the_lowerer_reach_and_pins_it() { weksa::run::LowererReach::BriefedSubagent ); } + +/// Answers the Dao encode prompts with the Dao packets and every lowering with +/// one sentence. +struct DaoModel; + +impl weksa::model::ModelPort for DaoModel { + fn model(&self) -> &str { + "fake-model" + } + fn answer(&mut self, prompt: &weksa::context::Prompt) -> Result { + if prompt.text().starts_with(weksa::context::LOWER_INSTRUCTIONS) { + return Ok(r#"{"text":"The highest excellence is like the water."}"#.into()); + } + let n: usize = prompt.item_keys()[0].rsplit(':').next().unwrap().parse().unwrap(); + Ok(serde_json::to_string(&weksa::PacketDraft::of(&weksa::daodejing::packets()[n - 1])).unwrap()) + } +} + +#[test] +fn loop_ablate_waits_then_prints_one_line_per_split() { + let d = tempfile::tempdir().unwrap(); + open_dao_run(d.path()); + let url = fake_embedder(); + let root = d.path().join("canaryroot"); + + // Record round 1 the way `loop round` does, then put its lowering answers + // where the relay would have. + let mut run = weksa::run::RunStores::open(&root).unwrap(); + let scorer = weksa::Scorer::new(weksa::OllamaEmbedder::new(&url, &run.record().embedder)); + let weksa::run::RoundOutcome::Recorded(r) = weksa::run::round(&mut run, &mut DaoModel, &scorer).unwrap() + else { + panic!("answered") + }; + for call in &r.calls()[3..] { + std::fs::write( + root.join(format!("round-1/{}.answer", call.prompt)), + r#"{"text":"The highest excellence is like the water."}"#, + ) + .unwrap(); + } + + let ablate = || weksa(d.path(), &["loop", "ablate", "canaryroot", "--round", "1", "--ollama-url", &url]); + let first = ablate(); + assert_eq!(first.status.code(), Some(3), "{}", out(&first)); + let text = out(&first); + let digests: Vec<&str> = text + .lines() + .filter_map(|l| l.strip_prefix("unanswered ")) + .filter(|d| d.len() == 16) + .collect(); + // Three replays, three drops and the Validation permutes (2 items). + assert_eq!(digests.len(), 3 + 3 + 2, "{text}"); + for digest in &digests { + assert!(root.join(format!("ablation-1/{digest}.prompt")).is_file()); + std::fs::write( + root.join(format!("ablation-1/{digest}.answer")), + r#"{"text":"The highest excellence is like the water."}"#, + ) + .unwrap(); + } + let second = ablate(); + assert!(second.status.success(), "{}", out(&second)); + let text = out(&second); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines[0].starts_with("train ablation n=1 "), "{text}"); + assert!(lines[1].starts_with("validation ablation n=2 "), "{text}"); + for leaked in ["item:", "packet:", "highest excellence", "canaryroot"] { + assert!(!text.contains(leaked), "{text}"); + } +} diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index 0929e53..c9c4bd6 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -2598,9 +2598,19 @@ fn source_tripwires() { let count = |needle: &str| code.iter().filter(|l| l.contains(needle)).count(); assert!(!run.contains("pub fn new")); // The round does not call the template table: the model lowers. - assert_eq!(count("lower(&"), 0, "run.rs calls the table"); + assert_eq!( + code.iter() + .filter(|l| l.contains("lower(&") && !l.contains("Prompt::lower(")) + .count(), + 0, + "run.rs calls the table" + ); assert_eq!(count("crate::lower::lower"), 0, "run.rs imports the table"); - assert_eq!(count("Prompt::lower("), 1, "one lowering call site"); + assert_eq!( + count("Prompt::lower("), + 2, + "two lowering call sites: the round's and the stance ablation's replay of it" + ); // Prompt::lower takes the view, the item's key, the inventory and the entity // port: no corpus item, train set, offer or store. let start = context.find("pub fn lower(").unwrap(); @@ -3560,3 +3570,441 @@ fn own_family_counts_offered_items_of_the_answerer_family() { assert_eq!(mixed_family_round(ModelFamily::Google), 0); assert_eq!(mixed_family_round(ModelFamily::Unrecorded), 0); } + +// ---- stance ablation (stance-to-lowerer) ---- + +/// A lowerer by function: the text it answers for an item key and the prompt +/// text, recording every prompt it was shown. +struct Lowerer { + f: fn(&str, &str) -> String, + seen: Vec<(String, String, String)>, +} + +impl Lowerer { + fn new(f: fn(&str, &str) -> String) -> Self { + Self { f, seen: vec![] } + } + /// The digests asked, once each, in the order first asked. + fn digests(&self) -> Vec { + let mut out: Vec = Vec::new(); + for (d, ..) in &self.seen { + if !out.contains(d) { + out.push(d.clone()); + } + } + out + } +} + +impl ModelPort for Lowerer { + fn model(&self) -> &str { + "fake-model" + } + fn answer(&mut self, prompt: &Prompt) -> Result { + let key = prompt.item_keys()[0].clone(); + self.seen.push((prompt.digest(), key.clone(), prompt.text().to_string())); + Ok(serde_json::json!({ "text": (self.f)(&key, prompt.text()) }).to_string()) + } +} + +const WRITTEN: &str = "item:written:1"; + +fn english(key: &str) -> String { + let mut table = table_english(); + table.insert( + WRITTEN.into(), + "Heaven endures through the long years without end.".into(), + ); + table[key].clone() +} + +/// The packet line's stance, as the prompt prints it: the packet line is one +/// JSON object ending in the stance. +fn stance_of(text: &str) -> String { + let line = text.lines().find(|l| l.starts_with("packet: ")).unwrap(); + line[line.find("\"stance\":").unwrap()..].to_string() +} + +/// Says the same thing whatever the stance. +fn blind(key: &str, _text: &str) -> String { + english(key) +} + +/// Appends the stance it was shown. +fn reader(key: &str, text: &str) -> String { + format!("{} {}", english(key), stance_of(text)) +} + +/// Does better with the item's own stance: the real sentence for it, nonsense +/// for any other, empty included. +fn helped(key: &str, text: &str) -> String { + let r800 = r#""stance":{"features":[{"feature":"feature:18","value":{"Grade":800}}]}}"#; + let own = match key { + "item:daodejing:3" => r#""stance":{"features":[{"feature":"feature:18","value":{"Grade":800}},{"feature":"feature:16","value":{"Grade":300}}]}}"#, + WRITTEN => r#""stance":{"features":[{"feature":"feature:18","value":{"Grade":500}}]}}"#, + _ => r800, + }; + if stance_of(text) == own { english(key) } else { "zzz qqq xxx".into() } +} + +fn grade_stance(features: &[(&str, u16)]) -> weksa::packet::Stance { + weksa::packet::Stance { + features: features + .iter() + .map(|(f, v)| weksa::packet::FeatureValue { + feature: f.parse().unwrap(), + value: weksa::packet::FeatValue::Grade(common::grade(*v)), + }) + .collect(), + } +} + +/// A Dao run with a third Validation item (`item:written:1`, key order after +/// items 2 and 3) and round 1 recorded over it, every item encoded to its Dao +/// packet. Stances: item 1 restraint 800; item 2 restraint 800; item 3 +/// restraint 800 and affection 300; the written item restraint 500. +fn recorded_dao(edit: impl FnOnce(&mut HashMap)) -> (Dao, RoundRecord) { + let mut dao = dao_over(|corpus, entities| { + let entities = weksa::EntityStore::open(entities).unwrap(); + let mut store = CorpusStore::open(corpus).unwrap(); + let mut written = store.get("item:daodejing:2").unwrap().unwrap(); + written.id = WRITTEN.into(); + written.text = "Heaven endures through the long years without end.".into(); + written.source = weksa::corpus::Source::OperatorWritten; + store.add(written, &entities).unwrap(); + }); + let mut model = fake(&good_answers()); + let mut extra = draft_of(2); + extra.referents.truncate(1); + extra.predications.truncate(1); + extra.stance = grade_stance(&[("feature:18", 500)]); + model.answers.insert(WRITTEN.into(), json(&extra)); + edit(&mut model.answers); + model.lowered.insert(WRITTEN.into(), english(WRITTEN)); + let r = recorded(round(&mut dao.run, &mut model, &Scorer::new(Bag)).unwrap()); + (dao, r) +} + +fn ablate( + dao: &Dao, + recorded: &mut dyn ModelPort, + model: &mut dyn ModelPort, +) -> Result { + dao.run.stance_ablation(1, recorded, model, &Scorer::new(Bag)) +} + +fn read( + outcome: weksa::run::AblationOutcome, +) -> Vec<(weksa::Split, weksa::run::AblationReading)> { + match outcome { + weksa::run::AblationOutcome::Read(r) => r, + weksa::run::AblationOutcome::Unanswered { prompts } => panic!("unanswered {prompts:?}"), + } +} + +#[test] +fn recorded_answers_read_and_never_write() { + use std::io::ErrorKind; + use weksa::model::RecordedAnswers; + let p = prompt(); + let dir = tempfile::tempdir().unwrap(); + let mut port = RecordedAnswers::new(dir.path().to_path_buf(), "answerer".into()); + assert_eq!(port.model(), "answerer"); + // A miss is Unanswered and the directory is as it was. + assert_eq!(port.answer(&p), Err(PortError::Unanswered)); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + // A directory that does not exist is a miss too, and is not made. + let mut absent = RecordedAnswers::new(dir.path().join("nope"), "m".into()); + assert_eq!(absent.answer(&p), Err(PortError::Unanswered)); + assert!(!dir.path().join("nope").exists()); + // A hit is the file's text as written. + let file = dir.path().join(format!("{}.answer", p.digest())); + std::fs::write(&file, " {\"x\": 1}\n").unwrap(); + assert_eq!(port.answer(&p).unwrap(), " {\"x\": 1}\n"); + std::fs::write(&file, [0xff, 0xfe]).unwrap(); + assert_eq!(port.answer(&p), Err(PortError::Io(ErrorKind::InvalidData))); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); +} + +#[test] +fn a_stance_ablation_asks_replay_drop_and_permute_for_train_and_validation_only() { + let (dao, r) = recorded_dao(|_| {}); + let round_digests: Vec = r.calls()[4..].iter().map(|c| c.prompt.clone()).collect(); + assert_eq!(round_digests.len(), 4, "the round lowered four items"); + let mut recorded_port = Lowerer::new(reader); + let mut model = Lowerer::new(reader); + read(ablate(&dao, &mut recorded_port, &mut model).unwrap()); + + // The base prompts are the round's own, byte for byte. + let recorded_digests = recorded_port.digests(); + assert_eq!(recorded_digests, round_digests); + let asked = model.digests(); + for d in &round_digests { + assert!(asked.contains(d), "the replay asks the round's prompt {d}"); + } + // Per item: replay and drop for each; permute for each Validation item, and + // none for the lone Train item. + let per_key = |key: &str| model.seen.iter().filter(|s| s.1 == key).count(); + assert_eq!(per_key("item:daodejing:1"), 2); + for key in ["item:daodejing:2", "item:daodejing:3", WRITTEN] { + assert_eq!(per_key(key), 3, "{key}"); + } + // Test items are never asked. + for (_, key, text) in &model.seen { + assert!(key != "item:daodejing:4" && key != "item:daodejing:5", "{key}"); + assert!(!text.contains("item:daodejing:4")); + } + // A drop prompt prints the empty stance, a replay the item's own. + let empty = model + .seen + .iter() + .filter(|s| stance_of(&s.2) == "\"stance\":{\"features\":[]}}") + .count(); + assert_eq!(empty, 4, "one drop per item"); +} + +#[test] +fn a_lowerer_blind_to_stance_reads_no_movement_and_one_that_reads_it_does() { + let (dao, _) = recorded_dao(|_| {}); + let one = |f: fn(&str, &str) -> String| { + let (mut rec, mut model) = (Lowerer::new(f), Lowerer::new(f)); + read(ablate(&dao, &mut rec, &mut model).unwrap()) + }; + for (split, r) in one(blind) { + assert_eq!(r.unusable, 0); + assert_eq!((r.drop.moved, r.permute.moved), (0, 0), "{split:?}: {}", r.line()); + assert!((r.replay.chrf - 100.0).abs() < 1e-9); + assert!((r.drop.chrf - r.replay.chrf).abs() < 1e-9); + if r.permute.n > 0 { + assert!((r.permute.chrf - r.replay.chrf).abs() < 1e-9); + } + assert_eq!(r.drop.ref_gain_chrf, 0.0); + } + let reading = one(reader); + let (train, validation) = (&reading[0].1, &reading[1].1); + assert_eq!((reading[0].0, reading[1].0), (weksa::Split::Train, weksa::Split::Validation)); + assert_eq!((train.n, validation.n), (1, 3)); + assert_eq!((train.drop.n, train.drop.moved), (1, 1)); + assert_eq!((train.permute.n, train.permute.moved), (0, 0)); + assert_eq!((validation.drop.n, validation.drop.moved), (3, 3)); + assert_eq!((validation.permute.n, validation.permute.moved), (3, 3)); + assert!((validation.replay.chrf - 100.0).abs() < 1e-9); + assert!(validation.drop.chrf < validation.replay.chrf); + assert!(validation.permute.chrf < validation.replay.chrf); +} + +#[test] +fn the_item_s_own_stance_helps_fidelity_only_for_a_lowerer_that_uses_it() { + let (dao, _) = recorded_dao(|_| {}); + let one = |f: fn(&str, &str) -> String| { + let (mut rec, mut model) = (Lowerer::new(f), Lowerer::new(f)); + read(ablate(&dao, &mut rec, &mut model).unwrap()) + }; + for (split, r) in one(helped) { + assert!(r.drop.ref_gain_chrf > 10.0, "{split:?}: {}", r.line()); + assert!(r.drop.ref_gain_meaning > 0.0, "{split:?}: {}", r.line()); + if r.permute.n > 0 { + assert!(r.permute.ref_gain_chrf > 10.0, "{split:?}: {}", r.line()); + } + assert_eq!(r.drop.moved, r.drop.n); + } + for (split, r) in one(blind) { + assert_eq!((r.drop.ref_gain_chrf, r.drop.ref_gain_meaning), (0.0, 0.0), "{split:?}"); + } +} + +#[test] +fn a_packet_without_stance_asks_no_drop_and_a_test_packet_in_the_round_is_never_asked() { + let (dao, _) = recorded_dao(|answers| { + let mut d = draft_of(3); + d.stance = Default::default(); + answers.insert("item:daodejing:3".into(), json(&d)); + }); + // A packet for a Test item, put into the round's own store the way no round + // would: the ablation reads splits from the corpus, not from the store. + { + let corpus = CorpusStore::open(&dao.corpus).unwrap(); + let inv = dao.run.inventory(); + let mut packet = packets().remove(3); + packet.inventory_rev = 3; + PacketStore::open(&dao.root.join("round-1/packets.cc")) + .unwrap() + .admit(packet, inv, &corpus, dao.run.entities()) + .unwrap(); + } + let (mut rec, mut model) = (Lowerer::new(reader), Lowerer::new(reader)); + read(ablate(&dao, &mut rec, &mut model).unwrap()); + let bare = "\"stance\":{\"features\":[]}}"; + let drops_of_3 = model + .seen + .iter() + .filter(|s| s.1 == "item:daodejing:3" && stance_of(&s.2) == bare) + .count(); + // Its replay already shows the empty stance; there is no drop beside it. + assert_eq!(drops_of_3, 1); + assert!(model.seen.iter().all(|s| s.1 != "item:daodejing:4"), "a Test item is never asked"); + assert!(rec.seen.iter().all(|s| s.1 != "item:daodejing:4")); +} + +#[test] +fn permute_takes_the_next_items_stance_in_its_split_and_skips_equal_ones() { + let (dao, _) = recorded_dao(|_| {}); + let (mut rec, mut model) = (Lowerer::new(reader), Lowerer::new(reader)); + read(ablate(&dao, &mut rec, &mut model).unwrap()); + // The stance each Validation item's permute prompt shows: the next item's + // in key order (2, 3, written:1), cyclic; item 1 is alone in Train. + let stance = |text: &str| stance_of(text); + let of = |key: &str| -> Vec { + model + .seen + .iter() + .filter(|s| s.1 == key) + .map(|s| stance(&s.2)) + .collect() + }; + let (s2, s3, sw) = ( + stance(&rec.seen.iter().find(|s| s.1 == "item:daodejing:2").unwrap().2), + stance(&rec.seen.iter().find(|s| s.1 == "item:daodejing:3").unwrap().2), + stance(&rec.seen.iter().find(|s| s.1 == WRITTEN).unwrap().2), + ); + assert!(s2 != s3 && s3 != sw && sw != s2); + assert!(of("item:daodejing:2").contains(&s3)); + assert!(of("item:daodejing:3").contains(&sw)); + assert!(of(WRITTEN).contains(&s2), "the last wraps to the first"); + assert!(!of("item:daodejing:2").contains(&sw), "not the previous or the other"); + // Item 1 is alone in Train: it never takes a stance across splits. + assert_eq!(of("item:daodejing:1").len(), 2, "replay and drop only"); + + // Items 2 and 3 with equal stances: item 2's permute is its own prompt, so + // it asks none; the others still do. + let (dao, _) = recorded_dao(|answers| { + let mut d = draft_of(3); + d.stance = draft_of(2).stance; + answers.insert("item:daodejing:3".into(), json(&d)); + }); + let (mut rec, mut model) = (Lowerer::new(reader), Lowerer::new(reader)); + let r = read(ablate(&dao, &mut rec, &mut model).unwrap()); + let per_key = |key: &str| model.seen.iter().filter(|s| s.1 == key).count(); + assert_eq!(per_key("item:daodejing:2"), 2, "replay and drop: its next holds the stance it has"); + assert_eq!(per_key("item:daodejing:3"), 3); + assert_eq!(per_key(WRITTEN), 3); + assert_eq!(r[1].1.permute.n, 2, "{}", r[1].1.line()); +} + +#[test] +fn a_stance_ablation_records_nothing_and_writes_nothing_in_its_round() { + use weksa::model::RecordedAnswers; + let (dao, r) = recorded_dao(|_| {}); + let round1 = dao.root.join("round-1"); + for c in &r.calls()[4..] { + std::fs::write( + round1.join(format!("{}.answer", c.prompt)), + serde_json::json!({ "text": english(&c.items[0]) }).to_string(), + ) + .unwrap(); + } + let snapshot = |dir: &Path| -> Vec<(String, Vec)> { + let mut files: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + let bytes = if e.path().is_dir() { vec![] } else { std::fs::read(e.path()).unwrap() }; + (e.file_name().to_string_lossy().into_owned(), bytes) + }) + .collect(); + files.sort(); + files.retain(|f| f.0 != "ablation-1"); + files + }; + let before = (snapshot(&round1), snapshot(&dao.root), dao.run.rounds().unwrap(), dao.run.decisions().unwrap()); + let go = || { + let mut rec = RecordedAnswers::new(round1.clone(), "fake-model".into()); + let mut files = weksa::model::AnswerFiles::new(dao.run.ablation_dir(1), "fake-model".into()); + // Nothing answers yet: the replay and variant prompts are written. + dao.run.stance_ablation(1, &mut rec, &mut files, &Scorer::new(Bag)) + }; + let weksa::run::AblationOutcome::Unanswered { prompts } = go().unwrap() else { + panic!("nobody answered") + }; + // Four replays (the round's own prompts), four drops, three permutes (item 1 + // is alone in Train). + assert_eq!(prompts.len(), 4 + 4 + 3, "{prompts:?}"); + for d in &prompts { + assert!(dao.run.ablation_dir(1).join(format!("{d}.prompt")).is_file()); + } + // Answer every ask from a fake and read; then again over the same files. + for d in &prompts { + let text = std::fs::read_to_string(dao.run.ablation_dir(1).join(format!("{d}.prompt"))).unwrap(); + std::fs::write( + dao.run.ablation_dir(1).join(format!("{d}.answer")), + serde_json::json!({ "text": format!("answer {}", &text[..8]) }).to_string(), + ) + .unwrap(); + } + let first = read(go().unwrap()); + let again = read(go().unwrap()); + assert_eq!(first, again); + let after = (snapshot(&round1), snapshot(&dao.root), dao.run.rounds().unwrap(), dao.run.decisions().unwrap()); + assert!(before.0 == after.0, "round-1 is unchanged byte for byte"); + assert!(before.1 == after.1, "the run root's files are unchanged"); + assert_eq!(before.2, after.2); + assert_eq!(before.3, after.3); +} + +#[test] +fn a_stance_ablation_refuses_a_cycle_round_and_missing_round_answers() { + use weksa::model::RecordedAnswers; + let (dao, _) = recorded_dao(|_| {}); + // No round-2, and round 1 holds no answer files. + let mut model = Lowerer::new(reader); + let mut rec = RecordedAnswers::new(dao.root.join("round-1"), "fake-model".into()); + assert!(matches!( + dao.run.stance_ablation(2, &mut rec, &mut model, &Scorer::new(Bag)), + Err(RunError::Malformed("round")) + )); + assert!(matches!( + ablate(&dao, &mut rec, &mut model), + Err(RunError::Store) + )); + assert!( + std::fs::read_dir(dao.root.join("round-1")) + .unwrap() + .all(|e| !e.unwrap().file_name().to_string_lossy().ends_with(".prompt")), + "no prompt is written into the round" + ); + // Drift is refused as a round refuses it. + struct Named(Lowerer); + impl ModelPort for Named { + fn model(&self) -> &str { + "other-model" + } + fn answer(&mut self, p: &Prompt) -> Result { + self.0.answer(p) + } + } + let mut named = Named(Lowerer::new(reader)); + let mut rec2 = Lowerer::new(reader); + assert!(matches!( + ablate(&dao, &mut rec2, &mut named), + Err(RunError::Drift(DriftOf::Model)) + )); + assert!(matches!( + dao.run.stance_ablation(1, &mut rec2, &mut Lowerer::new(reader), &Scorer::new(OtherEmbedder)), + Err(RunError::Drift(DriftOf::Embedder)) + )); +} + +#[test] +fn a_stance_ablation_refuses_a_cycle_round() { + let mut dao = dao(); + two_rounds(&mut dao); + run_cycle(&mut dao, &mut cycle_fake(ONE_CONCEPT)); + let rounds = dao.run.rounds().unwrap(); + let candidate = rounds.iter().find(|r| r.cycle().is_some()).expect("the cycle's round"); + let (mut rec, mut model) = (Lowerer::new(reader), Lowerer::new(reader)); + assert!(matches!( + dao.run.stance_ablation(candidate.n(), &mut rec, &mut model, &Scorer::new(Bag)), + Err(RunError::Malformed("round")) + )); + assert!(model.seen.is_empty() && rec.seen.is_empty()); +} From 633bf831dc3e4cfaeb3549b6653d760187f72551 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:27:23 +0000 Subject: [PATCH 15/81] Pin that the lowering prompt moves with the stance alone One dao packet admitted with its own stance, an anger-and-restraint stance and none: three digests, each packet line ending in exactly that stance's JSON, the rest of the text byte-equal, the stance features glossed in the one inventory listing. No prompt change; the lowerer already reads the stance. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/tests/context.rs | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/weksa/tests/context.rs b/crates/weksa/tests/context.rs index d6e2efe..32d3306 100644 --- a/crates/weksa/tests/context.rs +++ b/crates/weksa/tests/context.rs @@ -642,6 +642,50 @@ mod lowering { assert!(!prompt.text().contains(item.as_str())); } + #[test] + fn lower_prompt_moves_with_the_stance_alone() { + use weksa::packet::{FeatValue, FeatureValue, Grade, Stance}; + let (_, inv, entities, item) = dao(2); + let store = InventoryStore::open(&data("daodejing/inventory.cc")).unwrap(); + let own = packets().remove(2).stance; + let grade = |feature: &str, v: u16| FeatureValue { + feature: feature.parse().unwrap(), + value: FeatValue::Grade(Grade::new(v).unwrap()), + }; + let angry = Stance { + features: vec![grade("feature:9", 800), grade("feature:18", 900)], + }; + let prompt_of = |stance: Stance| { + let mut packet = packets().remove(2); + packet.stance = stance.clone(); + let view = admitted(packet, &store, "daodejing/corpus.cc", &entities); + let prompt = Prompt::lower(&view, &item, &inv, &entities).unwrap(); + (stance, prompt) + }; + let three = [prompt_of(own), prompt_of(angry), prompt_of(Stance::default())]; + + // Three stances, three prompts. + let digests: std::collections::BTreeSet = + three.iter().map(|(_, p)| p.digest()).collect(); + assert_eq!(digests.len(), 3); + // Each packet line ends with exactly that stance's JSON, and the prompt + // with the packet line taken out is the same text for all three. + let without_packet_line = |text: &str| -> String { + text.lines().filter(|l| !l.starts_with("packet: ")).collect::>().join("\n") + }; + let bare = without_packet_line(three[0].1.text()); + for (stance, prompt) in &three { + let line = prompt.text().lines().find(|l| l.starts_with("packet: ")).unwrap(); + let json = serde_json::to_string(stance).unwrap(); + assert!(line.ends_with(&format!(r#","stance":{json}}}"#)), "{line}"); + assert_eq!(without_packet_line(prompt.text()), bare); + } + // The stance's feature ids are glossed in the one inventory listing. + let text = three[1].1.text(); + assert!(text.contains("\nfeature:9: anger [grade; slots Stance]\n")); + assert!(text.contains("\nfeature:18: restraint [grade; slots Stance]\n")); + } + #[test] fn an_entity_referent_prints_its_en_us_forms_citation_first() { let cast = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap(); From 00feb1d31071fcebfdacdbee55a48a335091a8f1 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 18:28:14 +0000 Subject: [PATCH 16/81] Lowering brief states the grade scale LOWER_INSTRUCTIONS gains 'a grade is permille, 0 to 1000' (the encode brief already states it). The pinned lowering test requires the sentence and asserts the item's speaker entity id and group are in no prompt text. This edits context.rs, a PROMPT_SOURCE_DIGEST input: the digest moves and runs opened before this commit stop opening; open a fresh run to ablate under the new brief. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/context.rs | 2 +- crates/weksa/tests/context.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index 1a648a0..8e2cbfc 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -66,7 +66,7 @@ A feature is {feature, value}; value is {\"Grade\": 0 to 1000}, {\"Option\": lab /// Fixed per run by `PROMPT_SOURCE_DIGEST`. pub const LOWER_INSTRUCTIONS: &str = "\ Say the packet below in English (en-US), as one utterance of its speaker: all of its meaning and nothing it does not carry. -Every id is the inventory's listed below and every feature value is as listed. The stance is the speaker's felt affect and restraint: it shapes how the utterance is said, not what it says. +Every id is the inventory's listed below and every feature value is as listed; a grade is permille, 0 to 1000. The stance is the speaker's felt affect and restraint: it shapes how the utterance is said, not what it says. A referent with an entity is named by one of that entity's forms on the entities line. Answer with one JSON object and nothing else: {\"text\": \"...\"}. No code fence, no commentary."; diff --git a/crates/weksa/tests/context.rs b/crates/weksa/tests/context.rs index 32d3306..5979a26 100644 --- a/crates/weksa/tests/context.rs +++ b/crates/weksa/tests/context.rs @@ -630,6 +630,7 @@ mod lowering { for needed in [ "en-US", "all of its meaning and nothing it does not carry", + "a grade is permille, 0 to 1000", "felt affect and restraint", "shapes how the utterance is said, not what it says", "forms on the entities line", @@ -640,6 +641,17 @@ mod lowering { // The one call it makes is its item; the key is in no text. assert_eq!(prompt.item_keys(), [item.as_str()]); assert!(!prompt.text().contains(item.as_str())); + // The view's speaker and group are private: the item's speaker entity + // id and its group are in no text. + let held = CorpusStore::open(&data("daodejing/corpus.cc")) + .unwrap() + .get(item.as_str()) + .unwrap() + .unwrap(); + assert!(!held.group.is_empty()); + for private in [held.speaker.as_str(), held.group.as_str()] { + assert!(!prompt.text().contains(private), "{private}"); + } } #[test] From e8a9bbc5abce05a336890a87cf18e6b13eb92569 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 19:01:24 +0000 Subject: [PATCH 17/81] Ablation reads none for an empty set, pins its line, names a missing round answer Soul s1 findings: an empty mean printed 0.00 beside n=0 (now Option, printed none, like SplitMean); the printed line and whole ablate output are pinned by exact string, with drop and permute each carrying their own n; a missing recorded round answer is RunError::Unrecorded and says the prompt digest drifted or the head inventory moved, not that a store would not read; the unusable count is pinned for variant and item sides. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/run.rs | 45 +++++++---- crates/weksa/tests/cli.rs | 24 +++++- crates/weksa/tests/golf_loop.rs | 131 ++++++++++++++++++++++++++++---- 3 files changed, 167 insertions(+), 33 deletions(-) diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index 958edf1..dbbf6ad 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -455,6 +455,9 @@ pub enum RunError { NoFloor, /// A round has no Validation reading. NoValidation, + /// The round holds no answer to a lowering prompt the ablation rebuilt: the + /// prompt's digest drifted or the head inventory moved since the round. + Unrecorded, } impl fmt::Display for RunError { @@ -470,6 +473,9 @@ impl fmt::Display for RunError { RunError::NoInventory => f.write_str("the inventory holds no revision"), RunError::Io(kind) => write!(f, "io error {kind:?}"), RunError::Store => f.write_str("a store would not read or write"), + RunError::Unrecorded => f.write_str( + "the round holds no answer to a lowering prompt rebuilt now: the prompt digest drifted or the head inventory moved since the round", + ), RunError::Embed => f.write_str("the embedder failed"), RunError::NoFloor => f.write_str("the run has no noise floor: record two rounds first"), RunError::NoValidation => f.write_str("a reading has no validation item"), @@ -969,10 +975,12 @@ pub struct AblationReading { pub permute: VariantReading, } +/// Means over an empty set are `None`, printed `none`: no measurement, never +/// a zero. #[derive(Clone, Debug, PartialEq, Default)] pub struct Agreement { - pub chrf: f64, - pub meaning: f64, + pub chrf: Option, + pub meaning: Option, } /// One variant's reading: agreement with the recorded lowering, pairs whose @@ -981,27 +989,36 @@ pub struct Agreement { #[derive(Clone, Debug, PartialEq, Default)] pub struct VariantReading { pub n: u32, - pub chrf: f64, - pub meaning: f64, + pub chrf: Option, + pub meaning: Option, pub moved: u32, - pub ref_gain_chrf: f64, - pub ref_gain_meaning: f64, + pub ref_gain_chrf: Option, + pub ref_gain_meaning: Option, +} + +fn show(x: Option, places: usize) -> String { + x.map_or("none".to_string(), |x| format!("{x:.places$}")) } impl AblationReading { pub fn line(&self) -> String { let variant = |name: &str, v: &VariantReading| { format!( - "{name} n={} chrf={:.2} meaning={:.3} moved={} ref_gain_chrf={:.2} ref_gain_meaning={:.3}", - v.n, v.chrf, v.meaning, v.moved, v.ref_gain_chrf, v.ref_gain_meaning + "{name} n={} chrf={} meaning={} moved={} ref_gain_chrf={} ref_gain_meaning={}", + v.n, + show(v.chrf, 2), + show(v.meaning, 3), + v.moved, + show(v.ref_gain_chrf, 2), + show(v.ref_gain_meaning, 3) ) }; format!( - "ablation n={} unusable={} replay chrf={:.2} meaning={:.3} | {} | {}", + "ablation n={} unusable={} replay chrf={} meaning={} | {} | {}", self.n, self.unusable, - self.replay.chrf, - self.replay.meaning, + show(self.replay.chrf, 2), + show(self.replay.meaning, 3), variant("drop", &self.drop), variant("permute", &self.permute), ) @@ -1034,9 +1051,9 @@ fn asked(port: &mut dyn ModelPort, prompt: &Prompt) -> Result { } } -fn mean(xs: impl Iterator) -> f64 { +fn mean(xs: impl Iterator) -> Option { let (sum, n) = xs.fold((0.0, 0u32), |(s, n), x| (s + x, n + 1)); - if n == 0 { 0.0 } else { sum / f64::from(n) } + (n > 0).then(|| sum / f64::from(n)) } type Scored = (ItemScore, ItemScore); @@ -1143,7 +1160,7 @@ impl RunStores { } // The round's own answer must exist; a missing one is not a gap to ask for. let recorded_text = match asked(recorded, &base)? { - Asked::Waiting => return Err(RunError::Store), + Asked::Waiting => return Err(RunError::Unrecorded), other => other, }; let replay = asked(model, &base)?; diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 3d38bba..f5562cd 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -1696,11 +1696,27 @@ fn loop_ablate_waits_then_prints_one_line_per_split() { let second = ablate(); assert!(second.status.success(), "{}", out(&second)); let text = out(&second); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 2, "{text}"); - assert!(lines[0].starts_with("train ablation n=1 "), "{text}"); - assert!(lines[1].starts_with("validation ablation n=2 "), "{text}"); + assert_eq!( + text, + "train ablation n=1 unusable=0 replay chrf=100.00 meaning=1.000 | drop n=1 chrf=100.00 meaning=1.000 moved=0 ref_gain_chrf=0.00 ref_gain_meaning=0.000 | permute n=0 chrf=none meaning=none moved=0 ref_gain_chrf=none ref_gain_meaning=none\nvalidation ablation n=2 unusable=0 replay chrf=100.00 meaning=1.000 | drop n=2 chrf=100.00 meaning=1.000 moved=0 ref_gain_chrf=0.00 ref_gain_meaning=0.000 | permute n=2 chrf=100.00 meaning=1.000 moved=0 ref_gain_chrf=0.00 ref_gain_meaning=0.000\n" + ); for leaked in ["item:", "packet:", "highest excellence", "canaryroot"] { assert!(!text.contains(leaked), "{text}"); } } + +#[test] +fn loop_ablate_without_the_rounds_answers_says_so() { + let d = tempfile::tempdir().unwrap(); + open_dao_run(d.path()); + let url = fake_embedder(); + let root = d.path().join("canaryroot"); + let mut run = weksa::run::RunStores::open(&root).unwrap(); + let scorer = weksa::Scorer::new(weksa::OllamaEmbedder::new(&url, &run.record().embedder)); + weksa::run::round(&mut run, &mut DaoModel, &scorer).unwrap(); + let o = weksa(d.path(), &["loop", "ablate", "canaryroot", "--round", "1", "--ollama-url", &url]); + assert!(!o.status.success()); + let text = out(&o); + assert!(text.contains("holds no answer") && text.contains("head inventory"), "{text}"); + assert!(!text.contains("would not read"), "{text}"); +} diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index c9c4bd6..4a1c7ee 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -3773,12 +3773,12 @@ fn a_lowerer_blind_to_stance_reads_no_movement_and_one_that_reads_it_does() { for (split, r) in one(blind) { assert_eq!(r.unusable, 0); assert_eq!((r.drop.moved, r.permute.moved), (0, 0), "{split:?}: {}", r.line()); - assert!((r.replay.chrf - 100.0).abs() < 1e-9); - assert!((r.drop.chrf - r.replay.chrf).abs() < 1e-9); + assert!((r.replay.chrf.unwrap() - 100.0).abs() < 1e-9); + assert!((r.drop.chrf.unwrap() - r.replay.chrf.unwrap()).abs() < 1e-9); if r.permute.n > 0 { - assert!((r.permute.chrf - r.replay.chrf).abs() < 1e-9); + assert!((r.permute.chrf.unwrap() - r.replay.chrf.unwrap()).abs() < 1e-9); } - assert_eq!(r.drop.ref_gain_chrf, 0.0); + assert_eq!(r.drop.ref_gain_chrf, Some(0.0)); } let reading = one(reader); let (train, validation) = (&reading[0].1, &reading[1].1); @@ -3788,9 +3788,23 @@ fn a_lowerer_blind_to_stance_reads_no_movement_and_one_that_reads_it_does() { assert_eq!((train.permute.n, train.permute.moved), (0, 0)); assert_eq!((validation.drop.n, validation.drop.moved), (3, 3)); assert_eq!((validation.permute.n, validation.permute.moved), (3, 3)); - assert!((validation.replay.chrf - 100.0).abs() < 1e-9); - assert!(validation.drop.chrf < validation.replay.chrf); - assert!(validation.permute.chrf < validation.replay.chrf); + // A lone Train item has no permute: no measurement, not total disagreement. + assert_eq!( + train.permute, + weksa::run::VariantReading::default(), + "{}", + train.line() + ); + assert!( + train.line().contains( + "| permute n=0 chrf=none meaning=none moved=0 ref_gain_chrf=none ref_gain_meaning=none" + ), + "{}", + train.line() + ); + assert!((validation.replay.chrf.unwrap() - 100.0).abs() < 1e-9); + assert!(validation.drop.chrf.unwrap() < validation.replay.chrf.unwrap()); + assert!(validation.permute.chrf.unwrap() < validation.replay.chrf.unwrap()); } #[test] @@ -3801,15 +3815,15 @@ fn the_item_s_own_stance_helps_fidelity_only_for_a_lowerer_that_uses_it() { read(ablate(&dao, &mut rec, &mut model).unwrap()) }; for (split, r) in one(helped) { - assert!(r.drop.ref_gain_chrf > 10.0, "{split:?}: {}", r.line()); - assert!(r.drop.ref_gain_meaning > 0.0, "{split:?}: {}", r.line()); + assert!(r.drop.ref_gain_chrf.unwrap() > 10.0, "{split:?}: {}", r.line()); + assert!(r.drop.ref_gain_meaning.unwrap() > 0.0, "{split:?}: {}", r.line()); if r.permute.n > 0 { - assert!(r.permute.ref_gain_chrf > 10.0, "{split:?}: {}", r.line()); + assert!(r.permute.ref_gain_chrf.unwrap() > 10.0, "{split:?}: {}", r.line()); } assert_eq!(r.drop.moved, r.drop.n); } for (split, r) in one(blind) { - assert_eq!((r.drop.ref_gain_chrf, r.drop.ref_gain_meaning), (0.0, 0.0), "{split:?}"); + assert_eq!((r.drop.ref_gain_chrf, r.drop.ref_gain_meaning), (Some(0.0), Some(0.0)), "{split:?}"); } } @@ -3962,10 +3976,14 @@ fn a_stance_ablation_refuses_a_cycle_round_and_missing_round_answers() { dao.run.stance_ablation(2, &mut rec, &mut model, &Scorer::new(Bag)), Err(RunError::Malformed("round")) )); - assert!(matches!( - ablate(&dao, &mut rec, &mut model), - Err(RunError::Store) - )); + let Err(missing) = ablate(&dao, &mut rec, &mut model) else { + panic!("a missing round answer is refused") + }; + assert!(matches!(missing, RunError::Unrecorded)); + // It says what happened, not that a store would not read. + let said = missing.to_string(); + assert!(said.contains("holds no answer") && said.contains("digest") && said.contains("head inventory"), "{said}"); + assert!(!said.contains("would not read"), "{said}"); assert!( std::fs::read_dir(dao.root.join("round-1")) .unwrap() @@ -4008,3 +4026,86 @@ fn a_stance_ablation_refuses_a_cycle_round() { )); assert!(model.seen.is_empty() && rec.seen.is_empty()); } + +/// The real sentence for the item's own stance, an empty text (not a usable +/// lowering) for any other. +fn mute_unless_own(key: &str, text: &str) -> String { + let said = helped(key, text); + if said == "zzz qqq xxx" { String::new() } else { said } +} + +#[test] +fn unusable_variant_answers_are_counted_and_read_as_none_not_zero() { + let (dao, _) = recorded_dao(|_| {}); + let (mut rec, mut model) = (Lowerer::new(mute_unless_own), Lowerer::new(mute_unless_own)); + let reading = read(ablate(&dao, &mut rec, &mut model).unwrap()); + let (train, validation) = (&reading[0].1, &reading[1].1); + // Every variant answer is unusable: Train has one drop, Validation three + // drops and three permutes. The items themselves (replay) still read. + assert_eq!((train.n, train.unusable), (1, 1)); + assert_eq!((validation.n, validation.unusable), (3, 6)); + for r in [train, validation] { + assert!((r.replay.chrf.unwrap() - 100.0).abs() < 1e-9); + assert_eq!(r.drop.n, 0); + assert_eq!(r.permute.n, 0); + assert_eq!(r.drop, weksa::run::VariantReading::default()); + assert_eq!(r.permute, weksa::run::VariantReading::default()); + } + assert_eq!( + train.line(), + "ablation n=1 unusable=1 replay chrf=100.00 meaning=1.000 | drop n=0 chrf=none meaning=none moved=0 ref_gain_chrf=none ref_gain_meaning=none | permute n=0 chrf=none meaning=none moved=0 ref_gain_chrf=none ref_gain_meaning=none" + ); +} + +#[test] +fn the_line_names_each_variant_with_its_own_n_and_the_splits_n() { + use weksa::run::{AblationReading, Agreement, VariantReading}; + let reading = AblationReading { + n: 7, + unusable: 2, + replay: Agreement { chrf: Some(91.5), meaning: Some(0.9) }, + drop: VariantReading { + n: 5, + chrf: Some(80.25), + meaning: Some(0.8), + moved: 4, + ref_gain_chrf: Some(3.5), + ref_gain_meaning: Some(-0.125), + }, + permute: VariantReading { + n: 3, + chrf: Some(70.0), + meaning: None, + moved: 1, + ref_gain_chrf: None, + ref_gain_meaning: Some(0.5), + }, + }; + assert_eq!( + reading.line(), + "ablation n=7 unusable=2 replay chrf=91.50 meaning=0.900 | drop n=5 chrf=80.25 meaning=0.800 moved=4 ref_gain_chrf=3.50 ref_gain_meaning=-0.125 | permute n=3 chrf=70.00 meaning=none moved=1 ref_gain_chrf=none ref_gain_meaning=0.500" + ); +} + +fn nothing_usable(_: &str, _: &str) -> String { + String::new() +} + +#[test] +fn a_split_where_no_replay_is_usable_reads_none_throughout() { + let (dao, _) = recorded_dao(|_| {}); + let (mut rec, mut model) = (Lowerer::new(reader), Lowerer::new(nothing_usable)); + let reading = read(ablate(&dao, &mut rec, &mut model).unwrap()); + // Train: the item and its drop; Validation: three items, three drops, three permutes. + assert_eq!((reading[0].1.n, reading[0].1.unusable), (0, 2)); + assert_eq!((reading[1].1.n, reading[1].1.unusable), (0, 9)); + for (_, r) in &reading { + assert_eq!(r.replay, weksa::run::Agreement::default()); + assert_eq!(r.drop, weksa::run::VariantReading::default()); + assert_eq!(r.permute, weksa::run::VariantReading::default()); + } + assert_eq!( + reading[0].1.line(), + "ablation n=0 unusable=2 replay chrf=none meaning=none | drop n=0 chrf=none meaning=none moved=0 ref_gain_chrf=none ref_gain_meaning=none | permute n=0 chrf=none meaning=none moved=0 ref_gain_chrf=none ref_gain_meaning=none" + ); +} From 707e0204bf54559d1ff6c137abe9be2eb89fdcbf Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 19:27:48 +0000 Subject: [PATCH 18/81] Encode prompt: participants are node ids, never an entity id Both bad-form refusals in orig-5 put the speaker entity (entity:keeper) in context.participants. One sentence, pinned by a plain test; no new rule. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/context.rs | 2 +- crates/weksa/tests/context.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index 8e2cbfc..28c43db 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -59,7 +59,7 @@ Fields: - predications: a list of {id, concept, frame, roles, features}; roles is a list of {role, filler}, filler a node id. - relations: a list of {relation, from, to, features}. - stance: {features}; the speaker's felt affect and restraint, never the affect the line displays. -Node ids are n1, n2, and so on, one per referent and predication. Every other id comes from the inventory listed below and must be live at its revision. Write each id in full, exactly as the inventory lines spell it, family prefix included: concept:7, never 7. An entity comes only from the entities line. A referent's concept is never null, even when it carries an entity: give the nearest concept id. +Node ids are n1, n2, and so on, one per referent and predication. Every other id comes from the inventory listed below and must be live at its revision. Write each id in full, exactly as the inventory lines spell it, family prefix included: concept:7, never 7. An entity comes only from the entities line. A referent's concept is never null, even when it carries an entity: give the nearest concept id. Participants and audience list the node ids of referents, never an entity id: an entity speaker is a referent whose entity field holds it, and participants lists that referent's node id. A feature is {feature, value}; value is {\"Grade\": 0 to 1000}, {\"Option\": label} or {\"Flag\": true or false}, as the inventory lists the feature's kind."; /// The instructions a lowering prompt starts with: the lowerer's whole brief. diff --git a/crates/weksa/tests/context.rs b/crates/weksa/tests/context.rs index 5979a26..b908dcb 100644 --- a/crates/weksa/tests/context.rs +++ b/crates/weksa/tests/context.rs @@ -751,6 +751,7 @@ mod lowering { fn the_encode_prompt_states_the_id_form_the_inventory_lines_use() { assert!(ENCODE.contains("family prefix included: concept:7, never 7")); assert!(ENCODE.contains("A referent's concept is never null, even when it carries an entity: give the nearest concept id.")); + assert!(ENCODE.contains("Participants and audience list the node ids of referents, never an entity id: an entity speaker is a referent whose entity field holds it, and participants lists that referent's node id.")); let w = world(); let run = run_over(w.dir.path()); let inv = run.inventory().inventory().unwrap(); From ff3261a50f1423bbe0f08b8f63b4add93576cbce Mon Sep 17 00:00:00 2001 From: Weksa Date: Thu, 12 Nov 2026 09:46:50 +0000 Subject: [PATCH 19/81] Lows: show-working publishes by allowlist and prints Display, edition_year says first appearance The split guard failed open on any new split name; it now allows Train and split-less items only. The page printed Debug of Refusal/StoreError and the EntityRead store message; it prints Display and a fixed line. edition_year held the first-appearance year of the work or translation, not the page read: the doc comment and published schema say so. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/examples/show_working.rs | 6 +++--- crates/weksa/src/corpus.rs | 6 +++++- schemas/cultnet/weksa.corpus_item.schema.json | 3 ++- tools/show-working/build.py | 4 +++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/weksa/examples/show_working.rs b/crates/weksa/examples/show_working.rs index dae9ca9..8c23fb2 100644 --- a/crates/weksa/examples/show_working.rs +++ b/crates/weksa/examples/show_working.rs @@ -258,7 +258,7 @@ fn lower_error(e: &LowerError) -> Value { LowerError::DanglingStress { entity, at } => ("DanglingStress", format!("the ruled pronunciation of {entity} has a stress mark at {at} with no vowel after it")), LowerError::ConsentMissing { predication } => ("ConsentMissing", format!("{predication} is a request the packet does not carry the addressee's consent to")), LowerError::AskerNotAddressee { predication } => ("AskerNotAddressee", format!("the agent of {predication} is not the addressee")), - LowerError::EntityRead { entity, why } => ("EntityRead", format!("reading {entity}: {why}")), + LowerError::EntityRead { entity, .. } => ("EntityRead", format!("the entity store failed reading {entity}")), }; json!({ "variant": variant, "reason": reason }) } @@ -279,11 +279,11 @@ fn run(w: &World, dir: &Path, packet: &Packet) -> Result<(String, Value), Value> Err(StoreError::Invalid(rs)) => { return Err(json!({ "kind": "admission_refused", - "refusals": rs.iter().map(|r| json!({ "rule": r.rule(), "detail": format!("{r:?}") })).collect::>(), + "refusals": rs.iter().map(|r| json!({ "rule": r.rule(), "detail": r.to_string() })).collect::>(), })); } Err(e) => { - return Err(json!({ "kind": "admission_refused", "refusals": [{ "rule": "store", "detail": format!("{e:?}") }] })); + return Err(json!({ "kind": "admission_refused", "refusals": [{ "rule": "store", "detail": e.to_string() }] })); } }; let text = lower(&view, &w.inv, &w.entities).map_err(|e| { diff --git a/crates/weksa/src/corpus.rs b/crates/weksa/src/corpus.rs index a2f94ae..4afa662 100644 --- a/crates/weksa/src/corpus.rs +++ b/crates/weksa/src/corpus.rs @@ -32,7 +32,7 @@ pub enum Source { /// A public-domain work, with everything a reader needs to check the claim: /// the edition's licence and the jurisdiction the public-domain status is /// asserted in, who translated it (the translation is the text's copyright, - /// not the original's) and when the edition appeared. A work missing any + /// not the original's) and when the work or translation first appeared. A work missing any /// is refused at admission. PublicDomain { work: String, @@ -41,6 +41,10 @@ pub enum Source { licence: String, jurisdiction: String, translator: String, + /// The year the work, or for a translation this translation, first + /// appeared. It is not the year of the `edition_url` file's source + /// print: Project Gutenberg's text may be a later or modernised + /// edition, and the claim rests on the date of first appearance. edition_year: u16, }, /// Labelled model output; never in the test split. The rule that it is diff --git a/schemas/cultnet/weksa.corpus_item.schema.json b/schemas/cultnet/weksa.corpus_item.schema.json index b5e5bc9..1bd6083 100644 --- a/schemas/cultnet/weksa.corpus_item.schema.json +++ b/schemas/cultnet/weksa.corpus_item.schema.json @@ -170,7 +170,7 @@ ] }, { - "description": "A public-domain work, with everything a reader needs to check the claim:\nthe edition's licence and the jurisdiction the public-domain status is\nasserted in, who translated it (the translation is the text's copyright,\nnot the original's) and when the edition appeared. A work missing any\nis refused at admission.", + "description": "A public-domain work, with everything a reader needs to check the claim:\nthe edition's licence and the jurisdiction the public-domain status is\nasserted in, who translated it (the translation is the text's copyright,\nnot the original's) and when the work or translation first appeared. A work missing any\nis refused at admission.", "type": "object", "properties": { "PublicDomain": { @@ -183,6 +183,7 @@ "type": "string" }, "edition_year": { + "description": "The year the work, or for a translation this translation, first\nappeared. It is not the year of the `edition_url` file's source\nprint: Project Gutenberg's text may be a later or modernised\nedition, and the claim rests on the date of first appearance.", "type": "integer", "format": "uint16", "maximum": 65535, diff --git a/tools/show-working/build.py b/tools/show-working/build.py index 84e5189..ede5458 100755 --- a/tools/show-working/build.py +++ b/tools/show-working/build.py @@ -59,7 +59,9 @@ for m in re.finditer(r'^(item:\S+) \[(.*?)\]\n "(.*)"$', run([str(weksa_bin), " if set(corpus) != cited: sys.exit(f"corpus dump lacks cited items {sorted(cited - set(corpus))}; unexpected {sorted(set(corpus) - cited)}") # The dump's meta ends with the split (`... group=g Validation`, `... excluded`). -held_out = {k: v["meta"].split()[-1] for k, v in corpus.items() if v["meta"].split()[-1] in ("Validation", "Test")} +# Only Train and split-less items publish; any other name, including a split +# added later, is held out until it is allowed here. +held_out = {k: v["meta"].split()[-1] for k, v in corpus.items() if v["meta"].split()[-1] not in ("Train", "excluded")} if held_out: sys.exit(f"refusing to publish held-out items (nothing written): {held_out}") model = "qwen3-embedding:0.6b" From b83929d4d295caab46282c5784737878103c65df Mon Sep 17 00:00:00 2001 From: Weksa Date: Thu, 12 Nov 2026 12:16:19 +0000 Subject: [PATCH 20/81] Map: cut 9 ja-panel specced as ja-panel-loop and ja-panel-corpus; panel rows in Loop round and decision Co-Authored-By: Claude Sonnet 5.5 --- docs/targets/interlingua-lowering-map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 4846466..50c685b 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -40,8 +40,8 @@ means no store holds it and nothing writes it. | Scene (cut `speaker-scene`) | Key `scene:@`, the item's split group; type `weksa.scene`: participants (speaker slugs) and, per ordered pair, relation features of slot `Scene` (relative status, intimacy, in-group, audience and whatever the loop adds), at an inventory revision, and an `Annotator` | As Speaker. Keyed by the group, so a scene never straddles the split | As Speaker | | Corpus item | Key `item::`, `n` canonical decimal >= 1 (no sign, no leading zero, from cut 2b); type `weksa.corpus_item` with text, language, speaker, split group, prior turns, source. Source `ModelWritten` carries a `ModelFamily` enum, the generator string as the fixture wrote it, and a prompt digest (cut 2b). The item carries no speaker state of its own: its speaker slug and group name the Speaker and Scene documents | Admitted once; the whole item is immutable (re-adding an identical item is a no-op, any difference is refused; a correction is a new item that `supersedes` the old); text already held under another key is refused unless the new item supersedes that key (cut 2b); withdrawn, never deleted, so old scores stay explainable | Importers and `weksa corpus add`, under `corpus-cast-plus-public-domain`; the source enum has no variant for Discord or any training-forbidden source (`corpus-clean`) | | Split assignment | Derived: `split(scheme, item.group)` | Fixed for a scheme; a new scheme is a new constant, never a reassignment. A model-written item whose group derives test is in no split; a superseding item keeps its predecessor's group | The hash function. Nobody assigns by hand, so there is no writer to forbid | -| Loop round | Key `run: