Prompt::lower: the item's speaker becomes a fixed token with its forms #85

Merged
weksa merged 3 commits from weksa-lowering/speaker-alias-token into main 2026-11-13 17:28:14 +00:00
6 changed files with 196 additions and 38 deletions

View file

@ -260,6 +260,7 @@ fn lower_error(e: &LowerError) -> Value {
LowerError::AskerNotAddressee { predication } => ("AskerNotAddressee", format!("the agent of {predication} is not the addressee")),
LowerError::EntityRead { entity, .. } => ("EntityRead", format!("the entity store failed reading {entity}")),
LowerError::SceneUnread => ("SceneUnread", "the view's scene was never read".to_string()),
LowerError::SpeakerTokenTaken => ("SpeakerTokenTaken", "a referent cites the entity id the speaker token uses".to_string()),
};
json!({ "variant": variant, "reason": reason })
}

View file

@ -538,6 +538,13 @@ struct Meaning<'a> {
stance: &'a Stance,
}
/// What the lowering prompt prints in place of the item's speaker's entity id:
/// one constant, the same for every speaker and every item, so no id and
/// nothing derived from one reaches the lowerer. It parses as an entity id so
/// the packet keeps its shape. A real entity of this id is refused at lowering
/// ([`LowerError::SpeakerTokenTaken`]); the entity store does not reserve it.
pub const SPEAKER_TOKEN: &str = "entity:speaker";
/// Printed in place of a prior turn whose text no live Train item holds.
const ELIDED: &str = "[elided]";
@ -637,12 +644,18 @@ impl Prompt {
/// discourse and the scene relation `with_scene` read, and `language`,
/// never the caller; the scene's values are never printed, only the entries
/// they select. `item` goes only to [`Prompt::item_keys`], never into the
/// text. `speaker` is the item's speaker: it is never printed. A referent
/// citing it is printed with no entity, and it has no line in `entities:`
/// and no form to find, so a speaker the language has no form for does not
/// refuse. Any other cited entity is printed with its forms. A cited
/// entity the port lacks, or one with no form in `language`, is an error
/// and no prompt. The caller chooses the language; the view has none.
/// text. `speaker` is the item's speaker: its id is never printed. The
/// promise guards the id that names an entity of this item, not the
/// speaker's forms, which every item of the language shares. A referent
/// citing the speaker is printed with [`SPEAKER_TOKEN`] as its entity, and
/// the entities line holds the speaker's forms under that token. A speaker
/// the language has no form for, or the port lacks, does not refuse: its
/// referents print no entity and the line has no entry. Any other cited
/// entity is printed under its own id with its forms; one the port lacks,
/// or one with no form in `language`, is an error and no prompt. A
/// referent citing the token's own id as an entity is
/// [`LowerError::SpeakerTokenTaken`]. The caller chooses the language; the
/// view has none.
pub fn lower(
input: &LoweringInput,
item: &CorpusItemId,
@ -655,14 +668,28 @@ impl Prompt {
if input.scene_read() == &SceneRead::Unread {
return Err(LowerError::SceneUnread);
}
let mut forms: BTreeMap<&str, Vec<String>> = BTreeMap::new();
let token = EntityId::parse(SPEAKER_TOKEN).expect("the token is an entity id");
let mut referents = input.referents().to_vec();
for referent in &mut referents {
if referent.entity.as_ref() == Some(speaker) {
referent.entity = None;
if referents.iter().any(|r| r.entity.as_ref() == Some(&token)) {
return Err(LowerError::SpeakerTokenTaken);
}
let mut forms: BTreeMap<&str, Vec<String>> = BTreeMap::new();
if referents.iter().any(|r| r.entity.as_ref() == Some(speaker)) {
let spoken = match entity(entities, speaker) {
Ok(found) => found.forms_in(language).filter(|f| !f.is_empty()).map(<[String]>::to_vec),
Err(LowerError::UnknownEntity { .. }) => None,
Err(e) => return Err(e),
};
if let Some(spoken) = &spoken {
forms.insert(token.as_str(), spoken.clone());
}
for referent in &mut referents {
if referent.entity.as_ref() == Some(speaker) {
referent.entity = spoken.as_ref().map(|_| token.clone());
}
}
}
for id in referents.iter().filter_map(|r| r.entity.as_ref()) {
for id in referents.iter().filter_map(|r| r.entity.as_ref()).filter(|id| *id != &token) {
let found = entity(entities, id)?;
let named = found
.forms_in(language)

View file

@ -184,6 +184,9 @@ pub enum LowerError {
EntityRead { entity: EntityId, why: String },
/// A lowering prompt was asked of a view whose scene was never read.
SceneUnread,
/// A referent cites the entity whose id is the lowering prompt's speaker
/// token ([`crate::context::SPEAKER_TOKEN`]), which would read as the speaker.
SpeakerTokenTaken,
}
impl fmt::Display for LowerError {
@ -206,6 +209,7 @@ impl fmt::Display for LowerError {
LowerError::AskerNotAddressee { .. } => "request agent is not the addressee",
LowerError::EntityRead { .. } => "entity port failed to read",
LowerError::SceneUnread => "the view's scene was never read",
LowerError::SpeakerTokenTaken => "a referent cites the entity id the speaker token uses",
})
}
}

View file

@ -808,11 +808,11 @@ mod lowering {
}
}
/// The item's speaker is never printed, though a referent cites it: its
/// referent loses the entity, the forms map loses its line, and the view
/// itself is not changed.
/// The item's speaker id is never printed, though a referent cites it: the
/// referent holds the token, the forms line holds the speaker's forms under
/// it, and the view itself is not changed.
#[test]
fn a_cited_speaker_is_stripped_from_the_lowering_prompt() {
fn a_cited_speaker_is_replaced_by_the_token_in_the_lowering_prompt() {
let cast = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap();
let cast_entities = EntityStore::open(&data("entity/entity.cc")).unwrap();
let view = admitted(proof_packet(), &cast, "corpus/corpus.cc", &cast_entities);
@ -825,18 +825,76 @@ mod lowering {
};
let prompt = lower(&held).unwrap();
let text = prompt.text();
for banned in ["entity:nibu", "NIBUCANARY"] {
assert!(!text.contains(banned), "{banned} in {text}");
}
assert!(text.contains("\n\nentities: {}\n\n"), "{text}");
assert!(text.contains(r#""id":"n3","kind":"referent_kind:1","concept":"concept:5","entity":null"#), "{text}");
assert!(!text.contains("entity:nibu"), "{text}");
assert!(text.contains("\n\nentities: {\"entity:speaker\":[\"NIBUCANARY\"]}\n\n"), "{text}");
assert!(text.contains(r#""id":"n3","kind":"referent_kind:1","concept":"concept:5","entity":"entity:speaker""#), "{text}");
assert!(view.referents().iter().any(|r| r.entity.as_ref() == Some(&speaker)));
// A speaker the language has no form for, or the port lacks, does not refuse.
assert!(lower(&Held(vec![])).is_ok());
assert!(lower(&Held(vec![nibu(&[("sa-Latn", &["Nibuwa"])])])).is_ok());
// A speaker the language has no form for, or the port lacks, does not
// refuse: its referent names no entity and the line has no entry.
for held in [Held(vec![]), Held(vec![nibu(&[("sa-Latn", &["Nibuwa"])])])] {
let prompt = lower(&held).unwrap();
let text = prompt.text();
assert!(text.contains("\n\nentities: {}\n\n") && !text.contains("entity:speaker"), "{text}");
assert!(text.contains(r#""concept":"concept:5","entity":null"#), "{text}");
}
}
/// Only the speaker is stripped: when the item's speaker is someone else,
/// The speaker's forms are those of the item's language, and a language whose form list is
/// an empty list is no form: the line has no token entry.
#[test]
fn the_speakers_forms_are_those_of_the_items_language() {
let cast = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap();
let cast_entities = EntityStore::open(&data("entity/entity.cc")).unwrap();
let view = admitted(proof_packet(), &cast, "corpus/corpus.cc", &cast_entities);
let inv = cast.inventory().unwrap();
let item = CorpusItemId::parse("item:nibu:1").unwrap();
let speaker = EntityId::parse("entity:nibu").unwrap();
let lower = |tag: &str, held: &Held| {
Prompt::lower(&view, &item, &speaker, tag, &inv, held, &weksa::Ontology::empty())
.unwrap()
.text()
.to_string()
};
let held = Held(vec![nibu(&[("en-US", &["ENGLISHFORM"]), ("ja-JP", &["NIHONGOFORM"])])]);
let ja = lower("ja-JP", &held);
assert!(ja.contains("\n\nentities: {\"entity:speaker\":[\"NIHONGOFORM\"]}\n\n"), "{ja}");
assert!(!ja.contains("ENGLISHFORM"), "{ja}");
let en = lower("en-US", &held);
assert!(en.contains("{\"entity:speaker\":[\"ENGLISHFORM\"]}") && !en.contains("NIHONGOFORM"), "{en}");
let empty = lower("en-US", &Held(vec![nibu(&[("en-US", &[])])]));
assert!(empty.contains("\n\nentities: {}\n\n") && !empty.contains("entity:speaker"), "{empty}");
assert!(empty.contains(r#""concept":"concept:5","entity":null"#), "{empty}");
}
/// A real entity whose id is the token would read as the speaker: refused.
#[test]
fn a_cited_entity_with_the_tokens_id_is_refused() {
let cast = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap();
let mut packet = proof_packet();
packet.referents[2].entity = Some(EntityId::parse(weksa::context::SPEAKER_TOKEN).unwrap());
let dir = tempfile::tempdir().unwrap();
let mut entities = EntityStore::open(&dir.path().join("e.cc")).unwrap();
entities.put(super::common::ent("speaker", "en-US", &["Spk"])).unwrap();
entities.put(super::common::ent("nibu", "en-US", &["Nibu"])).unwrap();
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
let mut item = super::common::item_in("item:nibu:1", "A line.", &super::common::group_in_bucket(10), super::common::cast());
item.prior_turns = vec![weksa::Turn { speaker: super::common::eid("speaker"), text: "Well?".into() }];
corpus.add(item, &entities).unwrap();
let view = PacketStore::open(&dir.path().join("p.cc")).unwrap().admit(packet, &cast, &corpus, &entities).unwrap();
let state = weksa::SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
let view = view.with_scene(&cast.inventory().unwrap(), &state, &corpus).unwrap();
let item = CorpusItemId::parse("item:nibu:1").unwrap();
let held = Held(vec![super::common::ent("speaker", "en-US", &["Spk"]), nibu(&[("en-US", &["Nibu"])])]);
for speaker in ["entity:nibu", "entity:speaker"] {
let speaker = EntityId::parse(speaker).unwrap();
assert_eq!(
Prompt::lower(&view, &item, &speaker, "en-US", &cast.inventory().unwrap(), &held, &weksa::Ontology::empty()).err(),
Some(LowerError::SpeakerTokenTaken)
);
}
}
/// Only the speaker is replaced: when the item's speaker is someone else,
/// the cited entity keeps its id in the packet and its forms line.
#[test]
fn a_cited_entity_that_is_not_the_speaker_is_kept() {
@ -851,9 +909,9 @@ mod lowering {
assert!(text.contains("\n\nentities: {\"entity:nibu\":[\"NIBUCANARY\"]}\n\n"), "{text}");
}
/// Every referent citing the speaker is stripped, not the first.
/// Every referent citing the speaker holds the token, not the first.
#[test]
fn a_speaker_cited_on_two_referents_prints_on_neither() {
fn a_speaker_cited_on_two_referents_prints_the_token_on_both() {
let cast = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap();
let cast_entities = EntityStore::open(&data("entity/entity.cc")).unwrap();
let mut packet = proof_packet();
@ -865,8 +923,9 @@ mod lowering {
let held = Held(vec![nibu(&[("en-US", &["NIBUCANARY"])])]);
let prompt = Prompt::lower(&view, &item, &speaker, "en-US", &cast.inventory().unwrap(), &held, &weksa::Ontology::empty()).unwrap();
let text = prompt.text();
assert!(!text.contains("entity:nibu") && !text.contains("NIBUCANARY"), "{text}");
assert!(text.contains("\n\nentities: {}\n\n"), "{text}");
assert!(!text.contains("entity:nibu"), "{text}");
assert_eq!(text.matches(r#""entity":"entity:speaker""#).count(), 2, "{text}");
assert!(text.contains("\n\nentities: {\"entity:speaker\":[\"NIBUCANARY\"]}\n\n"), "{text}");
}
/// The speaker is matched by whole id: a cited entity whose id the
@ -885,6 +944,72 @@ mod lowering {
assert!(text.contains("NIBUCANARY"), "{text}");
}
/// The proof packet admitted for an item whose speaker is `speaker` and
/// whose prior turn is `other`'s, with `speaker` on referent n3 and `other`
/// on referent n1, then lowered in en-US over an entity port holding `held`.
fn lowered_for(speaker: &str, other: &str, held: &[Entity]) -> String {
let cast = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap();
let dir = tempfile::tempdir().unwrap();
let mut entities = EntityStore::open(&dir.path().join("e.cc")).unwrap();
for slug in [speaker, other] {
entities.put(super::common::ent(slug, "en-US", &["Stored"])).unwrap();
}
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
let mut item = super::common::item_in("item:nibu:1", "A line.", &super::common::group_in_bucket(10), super::common::cast());
item.speaker = super::common::eid(speaker);
item.prior_turns = vec![weksa::Turn { speaker: super::common::eid(other), text: "Well?".into() }];
corpus.add(item, &entities).unwrap();
let mut packet = proof_packet();
packet.referents[0].entity = Some(super::common::eid(other));
packet.referents[2].entity = Some(super::common::eid(speaker));
let view = PacketStore::open(&dir.path().join("p.cc"))
.unwrap()
.admit(packet, &cast, &corpus, &entities)
.unwrap();
let state = weksa::SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
let view = view.with_scene(&cast.inventory().unwrap(), &state, &corpus).unwrap();
let item = CorpusItemId::parse("item:nibu:1").unwrap();
Prompt::lower(
&view,
&item,
&super::common::eid(speaker),
"en-US",
&cast.inventory().unwrap(),
&Held(held.to_vec()),
&weksa::Ontology::empty(),
)
.unwrap()
.text()
.to_string()
}
/// Persona's ruling `speaker-alias-token-in-lowering-prompt`: the item's
/// speaker prints as one fixed role token, the same for every speaker and
/// every item, with the speaker's forms under it; its entity id never
/// prints. Another cited entity still prints under its own id.
#[test]
fn the_item_speaker_is_a_fixed_token_with_its_forms() {
const TOKEN: &str = "entity:speaker";
let forms = |slug: &str, form: &str| super::common::ent(slug, "en-US", &[form]);
let other = forms("othercite", "OTTERBLOOM");
let a = lowered_for("keeperzed", "othercite", &[forms("keeperzed", "ZEBRAQUILL"), other.clone()]);
let b = lowered_for("secondvoice", "othercite", &[forms("secondvoice", "MARMOTLUTE"), other.clone()]);
for (text, id, form, other_form) in [(&a, "keeperzed", "ZEBRAQUILL", "MARMOTLUTE"), (&b, "secondvoice", "MARMOTLUTE", "ZEBRAQUILL")] {
assert!(!text.contains(id), "{id} in {text}");
assert!(!text.contains(other_form), "{other_form} in {text}");
assert!(text.contains(r#""concept":"concept:5","entity":"entity:speaker""#), "{text}");
assert!(text.contains(&format!(r#""{TOKEN}":["{form}"]"#)), "{text}");
assert!(text.contains(r#""entity":"entity:othercite""#), "{text}");
assert!(text.contains(r#""entity:othercite":["OTTERBLOOM"]"#), "{text}");
}
// The token is one constant: the entities lines differ only in the speaker's form.
assert_eq!(a.replace("ZEBRAQUILL", "F"), b.replace("MARMOTLUTE", "F"));
// A speaker with no form in the language does not refuse and gets no token entry.
let bare = lowered_for("keeperzed", "othercite", &[other]);
assert!(!bare.contains("keeperzed") && !bare.contains(TOKEN), "{bare}");
assert!(bare.contains(r#"{"entity:othercite":["OTTERBLOOM"]}"#), "{bare}");
}
fn feature(inv: &Inventory, gloss: &str) -> weksa::InvId {
inv.entries().find(|e| e.gloss == gloss).unwrap().id
}

View file

@ -2889,8 +2889,9 @@ fn the_lowerer_sees_no_item_text() {
}
/// The promise `lower-prompt-no-item-text`: no lowering prompt holds the
/// item's speaker id. A referent citing the speaker must not put that entity
/// id (nor its forms) into the prompt; another cited entity still prints.
/// item's speaker id. A referent citing the speaker holds the fixed token
/// instead and its forms print under the token (forms are shared by every
/// item); another cited entity still prints under its own id.
#[test]
fn the_lowerer_never_sees_the_items_speaker() {
use weksa::entity::{Entity, EntityOrigin, LanguageForms};
@ -2947,11 +2948,10 @@ fn the_lowerer_never_sees_the_items_speaker() {
assert_eq!(with_other.len(), 1, "one lowering prompt cites the other entity");
let text = with_other[0];
assert!(text.contains("entity:othercite"), "{text}");
for banned in ["entity:spkcanary", "ZEBRAQUILL"] {
assert!(!text.contains(banned), "{banned} in {text}");
}
assert!(!text.contains("entity:spkcanary"), "{text}");
assert!(text.contains(r#""entity:speaker":["ZEBRAQUILL"]"#), "{text}");
for (_, t) in model.seen.iter().filter(|(_, t)| t.starts_with(LOWER_INSTRUCTIONS)) {
assert!(!t.contains("entity:spkcanary") && !t.contains("ZEBRAQUILL"));
assert!(!t.contains("entity:spkcanary"));
}
}
@ -4752,9 +4752,10 @@ fn each_item_lowers_in_its_own_language() {
} else {
en += 1;
assert!(text.contains("\n\nlanguage: en-US\n\n"), "{text}");
// Laozi speaks the en-US items: the lowerer is not told his name.
assert!(text.contains("\n\nentities: {}\n\n"), "{text}");
assert!(!text.contains("entity:laozi") && !text.contains("Laozi") && !text.contains("老子"), "{text}");
// Laozi speaks the en-US items: the lowerer gets his forms under the
// speaker token, never his id, and no ja-JP form.
assert!(text.contains("\n\nentities: {\"entity:speaker\":[\"Laozi\"]}\n\n"), "{text}");
assert!(!text.contains("entity:laozi") && !text.contains("老子"), "{text}");
}
}
assert_eq!((ja, en), (3, 3));

View file

@ -676,7 +676,7 @@ One line per cut; specs exist for the cuts about to run (2d and 4, siblings on `
7a. `loop-context-builder`: `context.rs` (`TrainItems`, `EncoderOffer`, `Prompt`: corpus text reaches a model call only through `Prompt::mint` over live Train items or `Prompt::encode` over one offered item; instructions are the consts `MINT_INSTRUCTIONS` and `ENCODE_INSTRUCTIONS` in `context.rs`; `Checkpoint` has no public constructor, offers each item once, and its offers borrow it (the gate is typed `TestReadings` and Test offers on the round path; operator tools such as `weksa corpus dump` and `weksa score` still read corpus text of any split); prior turns print the held Train text), `SplitScheme::live`, `CorpusItem::cited_entities` (one owner of the id set `PacketStore::admit` and the offer share), and checkpoint-only Test readings (`Checkpoint`, `TestReadings`; `Aggregate` and `leak_diagnostic` carry no Test). Library only; since 7b `TrainItems::from_corpus` and `EncoderOffer::for_round` are crate-private and `RunStores` is their one public producer. Merges follow-ups `loop-context-builder-2` and `scorer-test-readings-type`. (spec r1)
7b. `golf-loop-round` (spec r1; branch `weksa-lowering/golf-loop-round`): the smallest loop that scores one round. `run.rs`: `RunStores` pins the corpus and entity stores of a run by canonical path and FNV-1a 64 digest at `create`, reopens only from `<root>/run.cc` and refuses drift (corpus, entities, `PROMPT_SOURCE_DIGEST` of `context.rs`, a second pin on `lower.rs` retired in 7d, model, embedder); `round` encodes every live Train and Validation item through a `ModelPort` (`model.rs`; the one adapter `AnswerFiles` writes `<digest>.prompt` and reads `<digest>.answer`, so the model is the Claude session), reads each answer into a `PacketDraft` (`encode.rs`, the one place a model's JSON is read), admits through `PacketStore::admit` into `<root>/round-<n>/packets.cc`, lowers, scores once, and keeps a `RoundRecord` (`weksa.loop_round`, no text, no Test; Train rows, Validation only as means and refusal counts). `weksa loop open|round`. Not here: the spec-edit step, accept rule and composite, noise floor, Score and lowering documents, a subprocess adapter, the checkpoint door, a model lowerer (follow-ups `golf-loop-spec-edit`, `golf-loop-accept-rule`, `golf-loop-score-documents`, `golf-loop-subprocess-adapter`, `golf-loop-checkpoint-door-bound`, `golf-loop-model-lowerer`). `packet-store-inventory-binding` is met for loop packets only (`RunStores` admits with the run's inventory); `validate`, `packet proof|dao` and `lower` still take caller stores. Waits on 7a, `checkpoint-gate`, 4b.
7c. `golf-loop-edit-accept` (spec r1; branch `weksa-lowering/golf-loop-edit-accept`): one edit-accept cycle. The run works on its own copy of the inventory (`create` copies the caller's file to `<root>/inventory.cc`, refusing a root that already holds a file of that name, and pins its digest as `RunRecord` key 11; `RunRecord.inventory` is provenance; a `run.cc` from before has no key 11 and does not open); the head is derived from the latest Accepted `Decision`. `weksa loop cycle` asks the editor for one inventory edit from `Prompt::mint(train, inventory, rev, feedback)` (Train blocks, the inventory listing and an `EditFeedback` line: whether the last edit was kept and the head's Validation composite rounded to `LADDER_STEP`, the Ladder release of Blum and Hardt 2015), reads it as an `EditDraft` (`encode.rs`; ids and revisions are the store's, `into_revision`), admits it through `InventoryStore::admit_revision` into `<root>/cycle-<k>/inventory.cc`, runs a candidate round under it, and `decide`s on Validation against the head's establishing round past a floor (never under one `LADDER_STEP`; from cut `cycle-floor-paired` the paired floor over rounds 1 to `FLOOR_ARMS_MIN` + 1, so a cycle needs four recorded rounds, where 7c measured the spread between rounds 1 and 2). An edit that fails is a rejected `Decision`, not an error. `RoundRecord` gains keys 16 (inventory digest) and 17 (cycle); an encode answer that is not UTF-8 is that item's parse refusal. Not here: Score and lowering documents (`golf-loop-score-docs`), Train rows in the edit prompt (`golf-loop-edit-train-feedback`), a paired floor (`golf-loop-paired-floor`), the checkpoint door and merging to `data/` (`golf-loop-checkpoint-door-bound`, `golf-loop-freeze-merge`), a model lowerer (`golf-loop-model-lowerer-next`; until it lands an entry an edit adds has no lowering row, so a cycle gains only by retiring entries or adding ones the encoder does not cite). Waits on 7b.
7d. `golf-loop-model-lowerer` (spec r1; branch `weksa-lowering/golf-loop-model-lowerer`): the round lowers through the `ModelPort`. `Prompt::lower` (`context.rs`) takes a `LoweringInput` (no text, key, group or speaker getter), the item's speaker id, the inventory at the view's revision and the item language's entity forms, and prints `LOWER_INSTRUCTIONS`, the meaning of the packet, the entities line and the inventory listing; the item key goes only to `item_keys`, and the speaker id is stripped: a referent citing the item's speaker is printed with no entity, and the speaker has no line in the entities map and is never looked up (a speaker the language has no form for does not refuse; a speaker named in the text cannot be named by the lowerer). The answer is `{"text"}`, read as `LoweringDraft` in `encode.rs`, held in `score_round`'s locals and scored once; it is never put in `run.cc`, a `RoundRecord`, a `Decision` or `EditFeedback`. A round has two answer phases (every encode prompt, then every lowering prompt), all or nothing: while any prompt is unanswered `packets.cc` is discarded and nothing reaches `run.cc`. `lower::lower` and the template table leave the loop (they remain `weksa lower`'s; follow-up `table-retirement`), so an entry an edit adds can be said and a cycle can buy a gain. One source pin remains: `PROMPT_SOURCE_DIGEST` covers `context.rs`, `split.rs` and `lower.rs` (`RunRecord` key 7 and `RoundRecord` key 3 are retired). The run's record pins `LowererReach` (`weksa loop open --lowerer-reach briefed-subagent|tool-less-relay`, `RunRecord` key 12) and every printed round and cycle line says it; a briefed-subagent run says its numbers are smoke-round only (rulings `lowerer-call-reach-toolless-relay`, `dao-round-seed-inventory`). A lowering is refused at `Lower` when blank, over `MAX_LOWERING_BYTES` (4096) or holding a control character other than a line break or tab; `PROMPT_SOURCE_DIGEST` also covers `packet.rs`. Not here: per-item lowering text documents (`golf-loop-score-docs`), the subprocess adapter. Waits on 7c.
7d. `golf-loop-model-lowerer` (spec r1; branch `weksa-lowering/golf-loop-model-lowerer`): the round lowers through the `ModelPort`. `Prompt::lower` (`context.rs`) takes a `LoweringInput` (no text, key, group or speaker getter), the item's speaker id, the inventory at the view's revision and the item language's entity forms, and prints `LOWER_INSTRUCTIONS`, the meaning of the packet, the entities line and the inventory listing; the item key goes only to `item_keys`, and the speaker id never prints: a referent citing the item's speaker holds the fixed token `entity:speaker` (`SPEAKER_TOKEN`, a constant the same for every speaker and item, never derived from an id) and the entities map holds the speaker's forms under that token. The promise guards the id that names an entity of this item; the speaker's forms and the role token do print, because forms are shared by every item of the language (follow-up `speaker-alias-token-in-lowering-prompt`, which restored the lowerer's ability to name its speaker after #84 removed it). A speaker the language has no form for, or the port lacks, does not refuse: its referents print no entity and the map has no entry. A referent citing the real id `entity:speaker` is refused (`LowerError::SpeakerTokenTaken`); the entity store does not reserve it. The answer is `{"text"}`, read as `LoweringDraft` in `encode.rs`, held in `score_round`'s locals and scored once; it is never put in `run.cc`, a `RoundRecord`, a `Decision` or `EditFeedback`. A round has two answer phases (every encode prompt, then every lowering prompt), all or nothing: while any prompt is unanswered `packets.cc` is discarded and nothing reaches `run.cc`. `lower::lower` and the template table leave the loop (they remain `weksa lower`'s; follow-up `table-retirement`), so an entry an edit adds can be said and a cycle can buy a gain. One source pin remains: `PROMPT_SOURCE_DIGEST` covers `context.rs`, `split.rs` and `lower.rs` (`RunRecord` key 7 and `RoundRecord` key 3 are retired). The run's record pins `LowererReach` (`weksa loop open --lowerer-reach briefed-subagent|tool-less-relay`, `RunRecord` key 12) and every printed round and cycle line says it; a briefed-subagent run says its numbers are smoke-round only (rulings `lowerer-call-reach-toolless-relay`, `dao-round-seed-inventory`). A lowering is refused at `Lower` when blank, over `MAX_LOWERING_BYTES` (4096) or holding a control character other than a line break or tab; `PROMPT_SOURCE_DIGEST` also covers `packet.rs`. Not here: per-item lowering text documents (`golf-loop-score-docs`), the subprocess adapter. Waits on 7c.
7. `golf-loop-en` (7b and 7c landed the round, `ModelPort`, encoder prompt, template digest, round records and the edit-accept cycle; the rest remains): the packet store bound to one inventory store everywhere (`packet-store-inventory-binding`), a subprocess adapter, Score documents, `weksa score` in the loop, the detached unit on Yggdrasil. Waits on 3, 5, 6 and follow-up `claude-on-yggdrasil`.
8. `style-axis`: a style-embedding score behind the scorer port. Waits on 3.
9. `ja-panel`: ja-JP into the loop's panel, in two cuts on `e9cbd9b`. 9a `ja-panel-loop` (spec r1): language is the item's and the panel the run's: `Prompt::lower` takes the item's language and prints it on a language line (no prompt names English), entity forms are read in that language, `RunRecord` pins the panel (`weksa loop open --panel`), offers, mint and recall read only panel items, round means, floors and readings are per panel language, the accept rule runs `decide` per language, the recall split works on unspaced scripts, and `weksa corpus add` has no default language. 9b `ja-panel-corpus` (spec r1, after 9a): the ja-JP slice of `data/original`, model-written Japanese Train and Validation passages and eight Aozora Bunko public-domain Test sentences with full provenance, and their entities. Cut 5 is not a prerequisite: neither language lowers with ontology entries until cut 5 gives both theirs.