Show her working: public page of the lowering chain, from the real lowerer and scorer #15

Merged
weksa merged 4 commits from weksa-page/show-her-working into main 2026-11-10 17:43:20 +00:00
5 changed files with 618 additions and 0 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ build/
.venv/
venv/
target/
tools/show-working/out/

View file

@ -0,0 +1,377 @@
//! 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<Fixture> {
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<Value> {
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<Value> {
let rev = p.inventory_rev;
let mut ids: Vec<InvId> = 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::<Vec<_>>(),
"predications": p.predications.iter().map(|r| j(r)).collect::<Vec<_>>(),
"relations": p.relations.iter().map(|r| j(r)).collect::<Vec<_>>(),
},
"stance": j(&p.stance),
"discourse": j(&p.discourse),
"context": j(&p.context),
"extensions": p.extensions.iter().map(|r| j(r)).collect::<Vec<_>>(),
"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<T: serde::Serialize> 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::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:?}") })
}
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::<Vec<_>>(),
}));
}
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"));
}

16
scripts/publish-show-working.sh Executable file
View file

@ -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/<mind>/).
#
# 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

View file

@ -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.

215
tools/show-working/build.py Executable file
View file

@ -0,0 +1,215 @@
#!/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 -------------------------------------------------------
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):
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"
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")
# 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 = """
: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 <code>{e(r['entity'])}</code>" if r["entity"] else ""
rows.append(f"<tr><td><code>{e(r['id'])}</code></td><td>referent</td><td>{e(g(ch, r['kind']))} / {e(g(ch, r['concept']))}{ent}</td></tr>")
for p in s["meaning"]["predications"]:
roles = ", ".join(f"{e(g(ch, x['role']))}=<code>{e(x['filler'])}</code>" for x in p["roles"])
rows.append(f"<tr><td><code>{e(p['id'])}</code></td><td>predication</td><td>{e(g(ch, p['concept']))} in frame {e(g(ch, p['frame']))}: {roles}</td></tr>")
for r in s["meaning"]["relations"]:
rows.append(f"<tr><td></td><td>relation</td><td>{e(g(ch, r['relation']))}: <code>{e(r['from'])}</code> → <code>{e(r['to'])}</code></td></tr>")
meaning = "<table><tr><th>node</th><th>kind</th><th>content</th></tr>" + "".join(rows) + "</table>"
d = s["discourse"]
drows = []
if d["speech_act"]:
drows.append(f"<tr><td>speech act</td><td>{e(g(ch, d['speech_act']))}</td></tr>")
if d["addressee"]:
drows.append(f"<tr><td>addressee</td><td><code>{e(d['addressee'])}</code></td></tr>")
if d["addressee_consent"]:
drows.append(f"<tr><td>addressee consent</td><td>predication <code>{e(d['addressee_consent'])}</code> (what the addressee asked for)</td></tr>")
drows.append(f"<tr><td>source</td><td>{e(s['provenance']['source'])}, kind {e(s['kind'])}</td></tr>")
return (f"<h3>Slot: meaning</h3>{meaning}<h3>Slot: discourse</h3><table>{''.join(drows)}</table>"
+ 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"<tr><td>{e(f['gloss'])} <span class='meta'>{e(f['id'])}</span></td><td>{e(f['slot'])} @ {e(f['at'])}</td><td>{e(val(f['value']))}</td><td><span class='tag {cls}'>{e(note)}</span></td></tr>")
feats = ("<table><tr><th>feature</th><th>slot</th><th>value</th><th>measured effect on the lowerer</th></tr>" + "".join(rows) + "</table>") if rows else "<p class='meta'>No features: no hedge, certainty, polarity, irony or stance set.</p>"
return ("<h3>Slot: stance, hedge, certainty, restraint (features)</h3>" + feats
+ "<p class='meta'>Register: the packet has no register slot. Register and address belong to a target-language profile, not to the packet; this lowerer reads none.</p>")
def outcome_html(fx):
o = fx["outcome"]
if o["kind"] == "lowered":
ipa = o["ipa"]
ipa_s = ("IPA of cited entities: <span class='mono'>" + e(" ".join(ipa["ok"])) + "</span>") if "ok" in ipa else f"IPA refused: {e(ipa['error']['reason'])} <code>{e(ipa['error']['variant'])}</code>"
sc = []
for s in o["scores"]:
ref = corpus[s["item"]]["text"]
sc.append(f"<div class='ref'>{'Target' if s['target'] else 'Other reference'} <code>{e(s['item'])}</code>: <q>{e(ref)}</q></div>"
f"<table><tr><td width=110>chrF {s['chrf']:.1f}</td><td><div class='bar'><i style='width:{min(s['chrf'],100):.0f}%'></i></div></td>"
f"<td width=150>meaning cosine {s['meaning']:.3f}</td><td><div class='bar'><i style='width:{max(s['meaning'],0)*100:.0f}%'></i></div></td></tr></table>")
return (f"<h3>Lowered English</h3><div class='out'>{e(o['text'])}</div><p class='meta'>{ipa_s}</p>"
f"<h3>Closeness to the corpus ({e(data['build']['embedder'])})</h3>{''.join(sc)}")
if o["kind"] == "lower_error":
er = o["error"]
return f"<h3>Refused by the lowerer</h3><div class='refusal'><b>LowerError::{e(er['variant'])}</b>: {e(er['reason'])}<br><span class='mono'>{e(er['debug'])}</span></div><p class='meta'>No text, so no score: nothing was invented to score.</p>"
items = "".join(f"<li><b>{e(r['rule'])}</b> <span class='mono'>{e(r['detail'])}</span></li>" for r in o["refusals"])
return f"<h3>Refused at admission</h3><div class='refusal'><ul>{items}</ul></div><p class='meta'>The packet never became a lowering input, so the lowerer never saw it.</p>"
def card(fx):
o = fx["outcome"]
ch = "".join(f"<tr><td><code>{e(c['id'])}</code></td><td>{e(c['gloss'] or '(unresolved)')}</td></tr>" 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"<div><b>{e(a)}</b>{e(b)}</div>" for a, b in steps)
return (f"<article id='{e(fx['id'])}' data-fx><h2>{e(fx['title'])}</h2><p>{e(fx['note'])}</p>"
f"<p class='meta'>Packet <code>{e(fx['packet_id'])}</code> for <code>{e(fx['corpus_item'])}</code>. Made from: {e(fx['source'])}.</p>"
f"<div class='chain'>{chain}</div>{slots_html(fx)}"
f"<details><summary>Inventory entries chosen ({len(fx['chosen'])})</summary><table><tr><th>id</th><th>gloss at this revision</th></tr>{ch}</table></details>"
f"{outcome_html(fx)}</article>")
nav = "".join(f"<a class='{'lowered' if f['outcome']['kind']=='lowered' else 'refused'}' href='#{e(f['id'])}'>{e(f['title'])}</a>" for f in data["fixtures"])
b = data["build"]
page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Weksa: show her working</title><style>{CSS}</style></head><body>
<h1>Show her working</h1>
<p class="lede">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.</p>
<p class="meta">Built from <code>{e(b['commit'][:10])}</code>, embedder <code>{e(b['embedder'])}</code>. English only. Fixtures are the committed <code>nibu-proof-001</code> 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: <a href="data.json">data.json</a>.</p>
<nav class="pick">{nav}</nav>
{''.join(card(f) for f in data['fixtures'])}
<script>{PICK_JS}</script></body></html>"""
(out_dir / "index.html").write_text(page, encoding="utf-8")
print(f"wrote {out_dir}/index.html and data.json ({len(data['fixtures'])} fixtures)")