From 17f44144a5372ec80eefd318c31a8def35986b27 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 00:50:39 +0000 Subject: [PATCH 001/122] 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 a14bcaf9f830ef4921dc8316823e270a929ea6c8 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 05:00:34 +0000 Subject: [PATCH 002/122] The run stops opening the caller's inventory store: create copies it to /inventory.cc and pins its digest (key 11) RunRecord.inventory is provenance only; open refuses Drift(Inventory) when the run's own file differs. A run.cc from before has no key 11 and its open fails. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/run.rs | 33 ++++- crates/weksa/tests/golf_loop.rs | 144 +++++++++++++++++++++ schemas/cultnet/weksa.loop_run.schema.json | 8 +- 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index de6d846..46670c8 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -2,7 +2,11 @@ //! and the one writer of both (`RunStores::create` and, for rounds, `round`). //! //! A run pins its corpus and entity store files by canonical path and FNV-1a 64 -//! digest of their bytes. Every round reopens them only from that record and +//! digest of their bytes. It works on its own copy of the inventory: +//! `RunStores::create` copies the caller's inventory file to +//! `/inventory.cc` and pins the digest of the copy; the caller's path is +//! kept as provenance and never opened again, and `open` refuses a head file +//! whose digest differs. Every round reopens them only from that record and //! refuses on drift, and every round record carries the digests. Nothing inside //! a process whose writer controls the code can stop an item faked into a //! scratch store; what this makes true is that a run over a scratch store is a @@ -46,6 +50,9 @@ pub struct RunRecord { /// Canonical absolute path of the corpus store. #[cultcache(key = 1)] pub corpus: String, + /// Canonical absolute path of the inventory store the run was opened over: + /// provenance only. `create` copies that file to `/inventory.cc` and + /// the run reads nothing at this path afterwards. #[cultcache(key = 2)] pub inventory: String, #[cultcache(key = 3)] @@ -67,6 +74,9 @@ pub struct RunRecord { pub embedder: String, #[cultcache(key = 10)] pub chrf_version: String, + /// 16 hex of the bytes `create` copied to `/inventory.cc`. + #[cultcache(key = 11)] + pub inventory_digest: String, } /// Where a round refused: the first stage that did. @@ -276,6 +286,8 @@ pub enum DriftOf { Lowerer, Model, Embedder, + /// The run's own head inventory file. + Inventory, } /// A run's failure. Display and Debug name a kind, never a path, key, label or @@ -340,10 +352,17 @@ pub struct RunStores { entities: EntityStore, } -fn file_digest(path: &str) -> Option { +/// The run's own copy of the inventory, made at `create`. +const INVENTORY_FILE: &str = "inventory.cc"; + +fn file_digest_at(path: &Path) -> Option { std::fs::read(path).ok().map(|b| hex16(fnv1a64(&b))) } +fn file_digest(path: &str) -> Option { + file_digest_at(Path::new(path)) +} + fn canonical(path: &Path) -> Result { std::fs::canonicalize(path) .map_err(|e| RunError::Io(e.kind()))? @@ -403,10 +422,15 @@ impl RunStores { model: model.to_string(), embedder: embedder.to_string(), chrf_version: CHRF_VERSION.to_string(), + inventory_digest: String::new(), }; + // The run works on its own copy; the caller's store is never opened again. + let head = root.join(INVENTORY_FILE); + std::fs::copy(&record.inventory, &head).map_err(|e| RunError::Io(e.kind()))?; let record = RunRecord { corpus_digest: file_digest(&record.corpus).ok_or(RunError::Store)?, entity_digest: file_digest(&record.entities).ok_or(RunError::Store)?, + inventory_digest: file_digest_at(&head).ok_or(RunError::Store)?, ..record }; cache @@ -435,6 +459,9 @@ impl RunStores { if file_digest(&record.entities).as_ref() != Some(&record.entity_digest) { return Err(RunError::Drift(DriftOf::Entities)); } + if file_digest_at(&root.join(INVENTORY_FILE)).as_ref() != Some(&record.inventory_digest) { + return Err(RunError::Drift(DriftOf::Inventory)); + } Self::with(root, cache, record) } @@ -443,7 +470,7 @@ impl RunStores { Ok(Self { root: root.to_path_buf(), corpus: CorpusStore::open(Path::new(&record.corpus)).map_err(store)?, - inventory: InventoryStore::open(Path::new(&record.inventory)).map_err(store)?, + inventory: InventoryStore::open(&root.join(INVENTORY_FILE)).map_err(store)?, entities: EntityStore::open(Path::new(&record.entities)).map_err(store)?, cache, record, diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index 6631861..c2eec2b 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -120,6 +120,10 @@ fn open_checks_each_pin_by_name() { drift(&|r| r.entity_digest = "0".repeat(16)), Some(RunError::Drift(DriftOf::Entities)) ); + assert_eq!( + drift(&|r| r.inventory_digest = "0".repeat(16)), + Some(RunError::Drift(DriftOf::Inventory)) + ); assert_eq!(drift(&|_| {}), None, "the unchanged record opens"); // An entity put after the pin is another entity store. @@ -132,6 +136,146 @@ fn open_checks_each_pin_by_name() { ); } +#[test] +fn a_run_works_on_its_own_copy_of_the_inventory() { + let w = world(); + let dir = w.dir.path().to_path_buf(); + let root = dir.join("run"); + let (c, i, e) = paths(&dir); + let source_bytes = std::fs::read(&i).unwrap(); + let mut run = RunStores::create( + &root, + "t", + RunPaths { + corpus: &c, + inventory: &i, + entities: &e, + }, + "fake-model", + "bag", + ) + .unwrap(); + + // The copy is byte for byte the source, and the record pins its digest. + let copy = root.join("inventory.cc"); + assert_eq!(std::fs::read(©).unwrap(), source_bytes); + assert_eq!(run.record().inventory_digest, hex(&i)); + assert_eq!(run.record().inventory_digest, hex(©)); + assert_eq!( + run.record().inventory, + std::fs::canonicalize(&i).unwrap().to_str().unwrap(), + "the source path is provenance" + ); + let rev = run.inventory().inventory().unwrap().latest().unwrap().n; + + // A revision written to the source after open is a revision the run never + // reads, and it does not drift the run. + let mut source = weksa::InventoryStore::open(&i).unwrap(); + source + .admit_revision( + weksa::InventoryRevision { + n: rev + 1, + parent: Some(rev), + changes: vec![], + frozen: false, + }, + vec![], + ) + .unwrap(); + assert_ne!(hex(&i), run.record().inventory_digest); + run = RunStores::open(&root).unwrap(); + assert_eq!( + run.inventory().inventory().unwrap().latest().unwrap().n, + rev, + "the run reads its copy" + ); + assert_eq!(std::fs::read(©).unwrap(), source_bytes); + + // Even with the source gone the run opens. + std::fs::remove_file(&i).unwrap(); + assert!(RunStores::open(&root).is_ok()); + + // A revision appended to the copy is drift. + let mut head = weksa::InventoryStore::open(©).unwrap(); + head.admit_revision( + weksa::InventoryRevision { + n: rev + 1, + parent: Some(rev), + changes: vec![], + frozen: false, + }, + vec![], + ) + .unwrap(); + assert_eq!( + RunStores::open(&root).err(), + Some(RunError::Drift(DriftOf::Inventory)) + ); +} + +/// `weksa.loop_run` as it was before the run kept its own inventory copy: keys +/// 0 to 10. +#[derive(Clone, cultcache_rs::DatabaseEntry)] +#[cultcache(type = "weksa.loop_run")] +struct LegacyRun { + #[cultcache(key = 0)] + label: String, + #[cultcache(key = 1)] + corpus: String, + #[cultcache(key = 2)] + inventory: String, + #[cultcache(key = 3)] + entities: String, + #[cultcache(key = 4)] + corpus_digest: String, + #[cultcache(key = 5)] + entity_digest: String, + #[cultcache(key = 6)] + prompt_digest: String, + #[cultcache(key = 7)] + lowerer_digest: String, + #[cultcache(key = 8)] + model: String, + #[cultcache(key = 9)] + embedder: String, + #[cultcache(key = 10)] + chrf_version: String, +} + +#[test] +fn a_run_from_before_the_copy_does_not_open() { + // A run.cc without key 11 is a scratch run off git: its open fails Store. + let w = world(); + let dir = w.dir.path().to_path_buf(); + let current = run_over(&dir).record().clone(); + let root = dir.join("run"); + std::fs::remove_file(root.join("run.cc")).unwrap(); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache + .add_generic_backing_store(SingleFileMessagePackBackingStore::new(root.join("run.cc"))) + .unwrap(); + cache + .put( + "run:t", + &LegacyRun { + label: current.label, + corpus: current.corpus, + inventory: current.inventory, + entities: current.entities, + corpus_digest: current.corpus_digest, + entity_digest: current.entity_digest, + prompt_digest: current.prompt_digest, + lowerer_digest: current.lowerer_digest, + model: current.model, + embedder: current.embedder, + chrf_version: current.chrf_version, + }, + ) + .unwrap(); + assert_eq!(RunStores::open(&root).err(), Some(RunError::Store)); +} + #[test] fn source_digests_cover_their_files() { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); diff --git a/schemas/cultnet/weksa.loop_run.schema.json b/schemas/cultnet/weksa.loop_run.schema.json index b04b8b4..69bb0a4 100644 --- a/schemas/cultnet/weksa.loop_run.schema.json +++ b/schemas/cultnet/weksa.loop_run.schema.json @@ -26,6 +26,11 @@ "type": "string" }, "inventory": { + "description": "Canonical absolute path of the inventory store the run was opened over:\nprovenance only. `create` copies that file to `/inventory.cc` and\nthe run reads nothing at this path afterwards.", + "type": "string" + }, + "inventory_digest": { + "description": "16 hex of the bytes `create` copied to `/inventory.cc`.", "type": "string" }, "label": { @@ -53,6 +58,7 @@ "lowerer_digest", "model", "embedder", - "chrf_version" + "chrf_version", + "inventory_digest" ] } From fc52f3d3a49bb312fc42f46a869cd1c233b7ae5c Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 05:01:18 +0000 Subject: [PATCH 003/122] RoundRecord keys 16 inventory_digest and 17 cycle; round becomes the drift check, score_round and one put An encode answer the port reports as InvalidData is that item's Refused(Parse), not an aborted round. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/run.rs | 83 +++++++++++++++++--- crates/weksa/tests/golf_loop.rs | 30 +++++++ schemas/cultnet/weksa.loop_round.schema.json | 18 ++++- 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index 46670c8..a8ea6d6 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -147,6 +147,8 @@ pub struct RefusalCounts { /// validation_mean: SplitMean::default(), /// validation_scored: 0, /// validation_refused: RefusalCounts::default(), +/// inventory_digest: String::new(), +/// cycle: None, /// } /// } /// ``` @@ -190,6 +192,12 @@ pub struct RoundRecord { /// raise it. #[cultcache(key = 15)] validation_scored: u32, + /// 16 hex of the inventory file the round was scored under. + #[cultcache(key = 16)] + inventory_digest: String, + /// `Some(k)` for the candidate round of cycle `k`. + #[cultcache(key = 17)] + cycle: Option, } impl RoundRecord { @@ -241,6 +249,12 @@ impl RoundRecord { pub fn validation_scored(&self) -> u32 { self.validation_scored } + pub fn inventory_digest(&self) -> &str { + &self.inventory_digest + } + pub fn cycle(&self) -> Option { + self.cycle + } /// The one line a recorded round prints. `n` is every item of the split, /// `scored` those that lowered; a mean counts a refused item as 0. @@ -564,14 +578,53 @@ pub fn round( model: &mut M, scorer: &Scorer, ) -> Result { + check_drift(run, model, scorer)?; let store = |_| RunError::Store; + let dir = run.next_round_dir().map_err(store)?; + let digest = run.record.inventory_digest.clone(); + match score_round(run, &run.inventory, &digest, &dir, None, model, scorer)? { + RoundOutcome::Recorded(record) => { + run.cache + .put( + format!("run:{}:round:{}", run.record.label, record.n), + &record, + ) + .map_err(store)?; + Ok(RoundOutcome::Recorded(record)) + } + unanswered => Ok(unanswered), + } +} + +/// The model and embedder the run pinned are the ones answering. +fn check_drift( + run: &RunStores, + model: &M, + scorer: &Scorer, +) -> Result<(), RunError> { if model.model() != run.record.model { return Err(RunError::Drift(DriftOf::Model)); } if scorer.embedder().model() != run.record.embedder { return Err(RunError::Drift(DriftOf::Embedder)); } - let inventory = run.inventory.inventory().map_err(store)?; + Ok(()) +} + +/// Encodes, admits, lowers and scores every offered item under `inventory` +/// (whose file digests to `inventory_digest`), with packets in `dir`, and +/// returns the record unput. `Recorded` here means built, not stored. +fn score_round( + run: &RunStores, + inventory_store: &InventoryStore, + inventory_digest: &str, + dir: &Path, + cycle: Option, + model: &mut M, + scorer: &Scorer, +) -> Result { + let store = |_| RunError::Store; + let inventory = inventory_store.inventory().map_err(store)?; let rev = inventory.latest().ok_or(RunError::NoInventory)?.n; let train = run.train().map_err(store)?; @@ -581,7 +634,11 @@ pub fn round( for offer in run.offers().map_err(store)? { let prompt = Prompt::encode(&offer, &train, &inventory, rev); match model.answer(&prompt) { - Ok(answer) => answers.push((offer.key().to_string(), answer)), + Ok(answer) => answers.push((offer.key().to_string(), Some(answer))), + // An answer that is not UTF-8 is that item's parse refusal. + Err(PortError::Io(ErrorKind::InvalidData)) => { + answers.push((offer.key().to_string(), None)) + } Err(PortError::Unanswered) => unanswered.push(prompt.digest()), Err(PortError::Io(kind)) => return Err(RunError::Io(kind)), } @@ -596,10 +653,9 @@ pub fn round( }); } - let dir = run.next_round_dir().map_err(store)?; - std::fs::create_dir_all(&dir).map_err(|e| RunError::Io(e.kind()))?; + std::fs::create_dir_all(dir).map_err(|e| RunError::Io(e.kind()))?; // The directory of an unrecorded round holds only a failed attempt's packets. - discard_packets(&dir); + discard_packets(dir); let mut packets = PacketStore::open(&dir.join("packets.cc")).map_err(store)?; let encoder = encoder_id(model.model(), &run.record.prompt_digest); let mut items: Vec = Vec::new(); @@ -607,12 +663,12 @@ pub fn round( for (key, answer) in &answers { let item = run.corpus.get(key).map_err(store)?.ok_or(RunError::Store)?; let id = CorpusItemId::parse(key).map_err(|_| RunError::Store)?; - let fate = match PacketDraft::parse(answer) { - Err(_) => Fate::Refused(Stage::Parse, None), - Ok(draft) => { + let fate = match answer.as_deref().map(PacketDraft::parse) { + None | Some(Err(_)) => Fate::Refused(Stage::Parse, None), + Some(Ok(draft)) => { let packet = draft.into_packet(&id, rev, &encoder); let packet_key = packet.id.to_string(); - match packets.admit(packet, &run.inventory, &run.corpus, &run.entities) { + match packets.admit(packet, inventory_store, &run.corpus, &run.entities) { Err(_) => Fate::Refused(Stage::Admit, None), Ok(view) => match lower(&view, &inventory, &run.entities) { Err(_) => Fate::Refused(Stage::Lower, Some(packet_key)), @@ -647,7 +703,7 @@ pub fn round( match scorer.score(&inputs) { Ok(scores) => scores, Err(_) => { - discard_packets(&dir); + discard_packets(dir); return Err(RunError::Embed); } } @@ -720,10 +776,9 @@ pub fn round( validation_refused: refused, train_scored, validation_scored, + inventory_digest: inventory_digest.to_string(), + cycle, }; - run.cache - .put(format!("run:{}:round:{n}", run.record.label), &record) - .map_err(store)?; Ok(RoundOutcome::Recorded(record)) } @@ -759,6 +814,8 @@ mod tests { validation_mean: SplitMean::default(), validation_scored: 0, validation_refused: RefusalCounts::default(), + inventory_digest: String::new(), + cycle: None, }; assert_eq!(r.n(), 1); } diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index c2eec2b..71421ff 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -624,6 +624,8 @@ fn round_over_the_dao_stores_scores_train_and_validation() { assert_eq!(r.model(), "fake-model"); assert_eq!(r.embedder(), "bag"); assert_eq!(r.chrf_version(), weksa::score::CHRF_VERSION); + assert_eq!(r.inventory_digest(), pinned.inventory_digest); + assert_eq!(r.cycle(), None, "a plain round belongs to no cycle"); // It is stored, and the packets are in the round's own store, made by the encoder step. assert_eq!( @@ -946,6 +948,34 @@ fn refusal_stages_are_counted() { assert_eq!(full.validation_scored(), 2); } +#[test] +fn non_utf8_encode_answer_is_a_parse_refusal() { + let mut dao = dao(); + let expected = prompts(&dao.run); + let answers: HashMap = good_answers() + .into_iter() + .map(|(n, a)| (format!("item:daodejing:{n}"), a)) + .collect(); + let round1 = dao.root.join("round-1"); + std::fs::create_dir_all(&round1).unwrap(); + for (key, p) in &expected { + let bytes = if key == "item:daodejing:3" { + vec![0xff, 0xfe, 0xfd] + } else { + answers[key].clone().into_bytes() + }; + std::fs::write(round1.join(format!("{}.answer", p.digest())), bytes).unwrap(); + } + let mut files = AnswerFiles::new(round1.clone(), "fake-model".into()); + let r = recorded(round(&mut dao.run, &mut files, &Scorer::new(Bag)).unwrap()); + // Item 3 is Validation: its refusal is a Parse refusal and the round stands. + let v = r.validation_refused(); + assert_eq!((v.parse, v.admit, v.lower), (1, 0, 0)); + assert_eq!((r.train_scored(), r.validation_scored()), (1, 1)); + assert_eq!(r.validation_mean().count, 2); + assert_eq!(dao.run.rounds().unwrap(), [r]); +} + /// An embedder that is down. struct Down; diff --git a/schemas/cultnet/weksa.loop_round.schema.json b/schemas/cultnet/weksa.loop_round.schema.json index 7cde9c2..d846e12 100644 --- a/schemas/cultnet/weksa.loop_round.schema.json +++ b/schemas/cultnet/weksa.loop_round.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "RoundRecord", - "description": "One scored round. It holds no text and no Test reading: per-item rows for\nTrain only, Validation only as means and refusal counts by stage. Only\n`run::round` builds one.\n\n```\nfn f(r: &weksa::run::RoundRecord) -> u32 { r.n() }\n```\n\nEvery field is given, so the literal below fails only on privacy; its twin\nis the same literal in the unit test `a_round_record_literal_compiles_in_the_lib`.\n\n```compile_fail,E0451\nuse weksa::run::{RefusalCounts, RoundRecord};\nuse weksa::score::SplitMean;\nfn f() -> RoundRecord {\n RoundRecord {\n n: 1,\n inventory_rev: 1,\n prompt_digest: String::new(),\n lowerer_digest: String::new(),\n corpus_digest: String::new(),\n entity_digest: String::new(),\n model: String::new(),\n embedder: String::new(),\n chrf_version: String::new(),\n calls: vec![],\n train: vec![],\n train_mean: SplitMean::default(),\n train_scored: 0,\n validation_mean: SplitMean::default(),\n validation_scored: 0,\n validation_refused: RefusalCounts::default(),\n }\n}\n```", + "description": "One scored round. It holds no text and no Test reading: per-item rows for\nTrain only, Validation only as means and refusal counts by stage. Only\n`run::round` builds one.\n\n```\nfn f(r: &weksa::run::RoundRecord) -> u32 { r.n() }\n```\n\nEvery field is given, so the literal below fails only on privacy; its twin\nis the same literal in the unit test `a_round_record_literal_compiles_in_the_lib`.\n\n```compile_fail,E0451\nuse weksa::run::{RefusalCounts, RoundRecord};\nuse weksa::score::SplitMean;\nfn f() -> RoundRecord {\n RoundRecord {\n n: 1,\n inventory_rev: 1,\n prompt_digest: String::new(),\n lowerer_digest: String::new(),\n corpus_digest: String::new(),\n entity_digest: String::new(),\n model: String::new(),\n embedder: String::new(),\n chrf_version: String::new(),\n calls: vec![],\n train: vec![],\n train_mean: SplitMean::default(),\n train_scored: 0,\n validation_mean: SplitMean::default(),\n validation_scored: 0,\n validation_refused: RefusalCounts::default(),\n inventory_digest: String::new(),\n cycle: None,\n }\n}\n```", "type": "object", "properties": { "calls": { @@ -16,12 +16,25 @@ "corpus_digest": { "type": "string" }, + "cycle": { + "description": "`Some(k)` for the candidate round of cycle `k`.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, "embedder": { "type": "string" }, "entity_digest": { "type": "string" }, + "inventory_digest": { + "description": "16 hex of the inventory file the round was scored under.", + "type": "string" + }, "inventory_rev": { "type": "integer", "format": "uint32", @@ -85,7 +98,8 @@ "validation_mean", "validation_refused", "train_scored", - "validation_scored" + "validation_scored", + "inventory_digest" ], "$defs": { "Call": { From 6d17f45ca25aea7a4f362ad81ae61f25a9008c0f Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 05:02:42 +0000 Subject: [PATCH 004/122] The mint prompt becomes the editor's: Train blocks, the inventory listing and an EditFeedback line, nothing from the held-out split Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/context.rs | 173 +++++++++++++++++++++++++++++----- crates/weksa/src/run.rs | 7 ++ crates/weksa/tests/context.rs | 57 ++++++++--- 3 files changed, 204 insertions(+), 33 deletions(-) diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index 4dd5e30..152ff76 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -3,8 +3,10 @@ //! [`TrainItems`] or by encoding one item an [`EncoderOffer`] holds: Train-only //! minting and per-item encoding (leak-owned-by-flow-and-scorer-3, //! loop-context-builder-2). Instruction text is [`MINT_INSTRUCTIONS`] and -//! [`ENCODE_INSTRUCTIONS`], consts in this module; an encode prompt also lists -//! the inventory at the run's revision. +//! [`ENCODE_INSTRUCTIONS`], consts in this module; both prompts also list the +//! inventory at the run's revision. A mint prompt is the editor's: it reads +//! [`TrainItems`], an [`Inventory`] and an [`EditFeedback`] and nothing else, so +//! no held-out text, key or per-item score can reach it. //! //! Item blocks are data from the run's pinned corpus, reached only through //! `RunStores`, the one public producer of `TrainItems` and `EncoderOffer` @@ -17,12 +19,21 @@ use std::marker::PhantomData; use crate::corpus::CorpusItem; use crate::inventory::{Inventory, InventoryEntry, OptionLabel, ValueKind}; use crate::packet::EntityId; +use crate::run::LADDER_STEP; use crate::split::{Checkpoint, Split, SplitScheme, fnv1a64, hex16}; use crate::store::{CorpusStore, normalized}; -/// The instructions a mint prompt starts with. golf-loop-en edits the wording -/// here. -pub const MINT_INSTRUCTIONS: &str = "Mint glosses from the lines below."; +/// The instructions a mint prompt starts with: the editor's whole brief. Fixed +/// per run by `PROMPT_SOURCE_DIGEST`. +pub const MINT_INSTRUCTIONS: &str = "\ +Propose one edit to the inventory below, so that the lines below can be encoded and lowered to English more faithfully. +Answer with one JSON object and nothing else: no code fence, no commentary. +Shape: {\"add\": [{\"family\", \"gloss\", \"value_kind\", \"applies_to\"}], \"retire\": [ids]}; 1 to 8 changes in all, adds and retires together. +- family: one of Concept, Frame, Role, Relation, SpeechAct, Feature, ReferentKind, Extension. +- gloss: 1 to 120 bytes naming a general meaning, never a quoted line. +- value_kind and applies_to only on a Feature; value_kind only on an Extension. value_kind is \"Grade\", \"Flag\" or {\"Options\": [labels]}; applies_to is a list of slots. +- ids and revision numbers are the store's: write an id only to retire a live entry, by the id the inventory lists. +The line after this brief says whether your last edit was kept and the held-out composite score at the current inventory."; /// The instructions an encode prompt starts with: the encoder's whole brief. /// Fixed per run by `PROMPT_SOURCE_DIGEST`. @@ -214,6 +225,66 @@ impl<'cp> EncoderOffer<'cp> { } } +/// What the editor is told of how its last edit fared: whether it was kept, and +/// the held-out composite at the current inventory, rounded to the nearest +/// [`LADDER_STEP`]. The head composite changes only when an edit is accepted and +/// an accept needs a gain above one step, so a run releases at most +/// `1 / LADDER_STEP` distinct values (Blum and Hardt 2015, the Ladder). Nothing +/// else about the held-out split is a field. +/// +/// ``` +/// let f = weksa::context::EditFeedback::none(); +/// # let _ = f; +/// ``` +/// +/// Its fields are private: +/// +/// ```compile_fail,E0451 +/// let _ = weksa::context::EditFeedback { last: None, head: None }; +/// ``` +/// +/// and only the run builds one with a reading: +/// +/// ```compile_fail,E0624 +/// let _ = weksa::context::EditFeedback::new(Some(true), Some(0.5)); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EditFeedback { + last: Option, + head: Option, +} + +impl EditFeedback { + /// No edit yet and no reading. + pub fn none() -> Self { + Self { + last: None, + head: None, + } + } + + // Called by run::cycle (cut commit 5 of golf-loop-edit-accept). + #[allow(dead_code)] + /// `last`: whether the previous edit was accepted; `head`: the composite at + /// the current inventory, rounded here to the nearest [`LADDER_STEP`]. + pub(crate) fn new(last: Option, head: Option) -> Self { + Self { + last, + head: head.map(|x| (x / LADDER_STEP).round() * LADDER_STEP), + } + } + + fn line(&self) -> String { + let last = match self.last { + Some(true) => "accepted", + Some(false) => "rejected", + None => "none", + }; + let head = self.head.map_or("none".to_string(), |x| format!("{x:.3}")); + format!("last edit: {last}; validation composite at head: {head}") + } +} + /// Text for a model call, and the keys of the items it holds. The only /// constructors are [`Prompt::mint`] and [`Prompt::encode`]. /// @@ -224,23 +295,30 @@ impl<'cp> EncoderOffer<'cp> { /// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc") }; /// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); /// let t = run.train().unwrap(); -/// let p = weksa::context::Prompt::mint(&t); -/// assert_eq!(p.text(), weksa::context::MINT_INSTRUCTIONS); +/// let inv = run.inventory().inventory().unwrap(); +/// let feedback = weksa::context::EditFeedback::none(); +/// let p = weksa::context::Prompt::mint(&t, &inv, 0, &feedback); +/// assert!(p.text().starts_with(weksa::context::MINT_INSTRUCTIONS)); +/// assert!(p.text().contains("last edit: none; validation composite at head: none")); +/// assert!(p.text().contains("inventory rev:0")); /// ``` /// -/// Instructions are not a parameter: +/// Instructions are not a parameter; the same call with a fifth argument fails +/// on arity alone: /// -/// ```compile_fail +/// ```compile_fail,E0061 /// # let dir = tempfile::tempdir().unwrap(); /// # let p = |n: &str| dir.path().join(n); /// # for n in ["c.cc", "i.cc", "e.cc"] { std::fs::write(p(n), [0x90]).unwrap(); } /// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc") }; /// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); /// let t = run.train().unwrap(); -/// let p = weksa::context::Prompt::mint("x", &t); +/// let inv = run.inventory().inventory().unwrap(); +/// let feedback = weksa::context::EditFeedback::none(); +/// let p = weksa::context::Prompt::mint(&t, &inv, 0, &feedback, "x"); /// ``` /// -/// ```compile_fail +/// ```compile_fail,E0277 /// fn s(_: &T) {} /// # let dir = tempfile::tempdir().unwrap(); /// # let p = |n: &str| dir.path().join(n); @@ -248,7 +326,9 @@ impl<'cp> EncoderOffer<'cp> { /// # let paths = weksa::run::RunPaths { corpus: &p("c.cc"), inventory: &p("i.cc"), entities: &p("e.cc") }; /// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); /// let t = run.train().unwrap(); -/// let p = weksa::context::Prompt::mint(&t); +/// let inv = run.inventory().inventory().unwrap(); +/// let feedback = weksa::context::EditFeedback::none(); +/// let p = weksa::context::Prompt::mint(&t, &inv, 0, &feedback); /// s(&p); /// ``` pub struct Prompt { @@ -303,13 +383,33 @@ fn entry_line(entry: &InventoryEntry) -> String { line } +/// Each entry of `inventory` live at `rev`, one `\n` and one line apiece: the +/// listing both prompts print under `inventory rev:`. +fn inventory_lines(inventory: &Inventory, rev: u32) -> String { + inventory + .entries() + .filter(|e| e.live_at(rev)) + .map(|e| format!("\n{}", entry_line(e))) + .collect() +} + /// Printed in place of a prior turn whose text no live Train item holds. const ELIDED: &str = "[elided]"; impl Prompt { - /// [`MINT_INSTRUCTIONS`], then every Train item in key order. - pub fn mint(train: &TrainItems) -> Prompt { - let mut parts = vec![MINT_INSTRUCTIONS.to_string()]; + /// [`MINT_INSTRUCTIONS`], the feedback line, the entries of `inventory` live + /// at `rev`, then every Train item in key order without prior turns. + pub fn mint( + train: &TrainItems, + inventory: &Inventory, + rev: u32, + feedback: &EditFeedback, + ) -> Prompt { + let mut parts = vec![ + MINT_INSTRUCTIONS.to_string(), + feedback.line(), + format!("inventory rev:{rev}{}", inventory_lines(inventory, rev)), + ]; parts.extend(train.items.iter().map(|item| block(item, None))); Prompt { text: parts.join("\n\n"), @@ -327,15 +427,12 @@ impl Prompt { rev: u32, ) -> Prompt { let ids: Vec<&str> = offer.entities.iter().map(EntityId::as_str).collect(); - let mut text = format!( - "{ENCODE_INSTRUCTIONS}\n\n{}\n\nentities: {}\n\ninventory rev:{rev}", + let text = format!( + "{ENCODE_INSTRUCTIONS}\n\n{}\n\nentities: {}\n\ninventory rev:{rev}{}", block(&offer.item, Some(train)), - ids.join(", ") + ids.join(", "), + inventory_lines(inventory, rev) ); - for entry in inventory.entries().filter(|e| e.live_at(rev)) { - text.push('\n'); - text.push_str(&entry_line(entry)); - } Prompt { text, items: vec![offer.item.id.clone()], @@ -363,6 +460,38 @@ mod tests { use super::*; use crate::split::fixture::{group_in, item}; + #[test] + fn the_feedback_line_prints_exact_values() { + let line = |last, head| EditFeedback::new(last, head).line(); + assert_eq!( + EditFeedback::none().line(), + "last edit: none; validation composite at head: none" + ); + assert_eq!( + line(Some(true), Some(0.5)), + "last edit: accepted; validation composite at head: 0.500" + ); + assert_eq!( + line(Some(false), Some(0.0)), + "last edit: rejected; validation composite at head: 0.000" + ); + assert_eq!(line(None, None), EditFeedback::none().line()); + // The head is rounded to the nearest step before it is printed. + assert_eq!( + line(None, Some(0.5012)), + "last edit: none; validation composite at head: 0.500" + ); + assert_eq!( + line(None, Some(0.5026)), + "last edit: none; validation composite at head: 0.505" + ); + assert_eq!( + line(None, Some(0.9999)), + "last edit: none; validation composite at head: 1.000" + ); + assert_eq!(EditFeedback::new(None, Some(0.5026)).head, Some(0.505)); + } + #[test] fn checkpoint_offer_gate() { // A fresh checkpoint per call: this test is the gate, not the once rule. diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index a8ea6d6..4120100 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -41,6 +41,13 @@ pub const PROMPT_SOURCE_DIGEST: u64 = fnv1a64(include_bytes!("context.rs")); /// included, ends a run; code outside that file is not covered. pub const LOWERER_SOURCE_DIGEST: u64 = fnv1a64(include_bytes!("lower.rs")); +/// The least composite gain an edit must show over item noise to be kept, and +/// the grid the editor's one held-out number is rounded to. A run releases at +/// most `1 / LADDER_STEP` = 200 distinct head composites (the Ladder, Blum and +/// Hardt 2015), because the head changes only on an accept and an accept needs +/// a gain above one step. +pub const LADDER_STEP: f64 = 0.005; + /// What a run pinned at open. Key `run: