Show her working: public page of the lowering chain, from the real lowerer and scorer #15
5 changed files with 618 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ build/
|
|||
.venv/
|
||||
venv/
|
||||
target/
|
||||
tools/show-working/out/
|
||||
|
|
|
|||
377
crates/weksa/examples/show_working.rs
Normal file
377
crates/weksa/examples/show_working.rs
Normal 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
16
scripts/publish-show-working.sh
Executable 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
|
||||
9
tools/show-working/README.md
Normal file
9
tools/show-working/README.md
Normal 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
215
tools/show-working/build.py
Executable 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)")
|
||||
Loading…
Reference in a new issue