diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index b162e6d..dc1e19f 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -79,7 +79,7 @@ Answer with one JSON object and nothing else: {\"text\": \"...\"}. No code fence /// # 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 run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay).unwrap(); /// let t = run.train().unwrap(); /// assert!(t.is_empty()); /// ``` @@ -93,7 +93,7 @@ Answer with one JSON object and nothing else: {\"text\": \"...\"}. No code fence /// # 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 run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay).unwrap(); /// let t = run.train().unwrap(); /// let _u = t.clone(); /// ``` @@ -104,7 +104,7 @@ Answer with one JSON object and nothing else: {\"text\": \"...\"}. No code fence /// # 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 run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay).unwrap(); /// let t = run.train().unwrap(); /// s(&t); /// ``` @@ -310,7 +310,7 @@ impl EditFeedback { /// # 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 run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay).unwrap(); /// let t = run.train().unwrap(); /// let inv = run.inventory().inventory().unwrap(); /// let feedback = weksa::context::EditFeedback::none(); @@ -328,7 +328,7 @@ impl EditFeedback { /// # 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 run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay).unwrap(); /// let t = run.train().unwrap(); /// let inv = run.inventory().inventory().unwrap(); /// let feedback = weksa::context::EditFeedback::none(); @@ -341,7 +341,7 @@ impl EditFeedback { /// # 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 run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e", weksa::run::LowererReach::ToolLessRelay).unwrap(); /// let t = run.train().unwrap(); /// let inv = run.inventory().inventory().unwrap(); /// let feedback = weksa::context::EditFeedback::none(); @@ -498,8 +498,11 @@ impl Prompt { /// [`LOWER_INSTRUCTIONS`], the meaning of `input` (no kind, constraints, /// scene, speaker or group), the en-US forms of every entity its referents /// cite, and the entries of `inventory` live at the view's revision. `item` - /// goes only to [`Prompt::item_keys`], never into the text. A cited entity - /// the port lacks, or one with no en-US form, is an error and no prompt. + /// goes only to [`Prompt::item_keys`], never into the text. The view's + /// speaker and group fields are never read; a speaker's entity id appears + /// only when the encoder cites that entity on a referent, which is naming a + /// cited entity like any other. A cited entity the port lacks, or one with + /// no en-US form, is an error and no prompt. pub fn lower( input: &LoweringInput, item: &CorpusItemId, diff --git a/crates/weksa/src/encode.rs b/crates/weksa/src/encode.rs index 0a0d358..9aa08a0 100644 --- a/crates/weksa/src/encode.rs +++ b/crates/weksa/src/encode.rs @@ -53,7 +53,7 @@ impl fmt::Display for DraftError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "not a packet draft at line {} column {}", + "not a draft at line {} column {}", self.line, self.column ) } @@ -207,10 +207,26 @@ pub struct LoweringDraft { pub text: String, } +/// The most bytes a lowering may hold: well over any one utterance, far under +/// a megabyte the embedder would be asked to read. +pub const MAX_LOWERING_BYTES: usize = 4096; + impl LoweringDraft { pub fn parse(answer: &str) -> Result { from_json(answer) } + + /// Whether the text can be scored as an utterance: something after trimming, + /// at most [`MAX_LOWERING_BYTES`], and no control character other than a + /// line break or tab. Nothing is judged about the words. + pub fn usable(&self) -> bool { + !self.text.trim().is_empty() + && self.text.len() <= MAX_LOWERING_BYTES + && !self + .text + .chars() + .any(|c| c.is_control() && !matches!(c, '\n' | '\t')) + } } /// `encoder:`, a digest of the model id and the prompt digest. @@ -347,7 +363,7 @@ mod tests { fn a_draft_error_is_a_position_and_echoes_no_token() { let err = PacketDraft::parse("{\n \"confidence\": CANARYTOKEN").unwrap_err(); assert_eq!((err.line, err.column), (2, 17)); - assert_eq!(err.to_string(), "not a packet draft at line 2 column 17"); + assert_eq!(err.to_string(), "not a draft at line 2 column 17"); assert_eq!(format!("{err:?}"), err.to_string()); let err = PacketDraft::parse(r#"{"confidence":500,"CANARYKEY":1}"#).unwrap_err(); for text in [err.to_string(), format!("{err:?}")] { diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index d3e9f35..148f0be 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -11,7 +11,7 @@ use weksa::corpus::{CorpusItem, ModelFamily, NameSpan, Source, Turn}; use weksa::entity::{Entity, EntityOrigin, LanguageForms, Pronunciation}; use weksa::inventory::{proof_revision, seed}; use weksa::packet::proof_packet; -use weksa::run::{CycleOutcome, RoundOutcome, RunPaths, RunStores, cycle, round}; +use weksa::run::{CycleOutcome, LowererReach, RoundOutcome, RunPaths, RunStores, cycle, round}; use weksa::score::ScoreInput; use weksa::{ AnswerFiles, CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, PacketStore, @@ -88,6 +88,10 @@ enum LoopCommand { answerer: String, #[arg(long, default_value = "qwen3-embedding:0.6b")] embed_model: String, + /// How lowering prompts will be answered: briefed-subagent (smoke + /// rounds only) or tool-less-relay. Pinned in the run's record. + #[arg(long)] + lowerer_reach: LowererReach, }, /// Run one round of the run at ROOT. The stores are read only from the /// run's record: the global --corpus, --inventory and --entities are not @@ -734,6 +738,7 @@ fn main() -> Result<()> { label, answerer, embed_model, + lowerer_reach, }) => { let run = RunStores::create( &root, @@ -745,6 +750,7 @@ fn main() -> Result<()> { }, &answerer, &embed_model, + lowerer_reach, )?; println!("opened run:{}", run.record().label); } @@ -765,7 +771,7 @@ fn main() -> Result<()> { println!("unanswered {} of {asked}", prompts.len()); std::process::exit(3); } - RoundOutcome::Recorded(r) => println!("{}", r.summary_line()), + RoundOutcome::Recorded(r) => println!("{}", run.labelled(&r.summary_line())), } } Command::Loop(LoopCommand::Cycle { root, ollama_url }) => { @@ -784,7 +790,9 @@ fn main() -> Result<()> { println!("unanswered {} prompts", prompts.len()); std::process::exit(3); } - CycleOutcome::Decided(decision) => println!("{}", decision.summary_line()), + CycleOutcome::Decided(decision) => { + println!("{}", run.labelled(&decision.summary_line())) + } } } Command::Schema { out } => { diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index 3e511f2..1b6d6a3 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -46,7 +46,8 @@ use crate::validate::LoweringInput; /// The digest of the prompt builder's source: `context.rs`, then `split.rs`, /// whose scheme and digest function decide which lines the prompt holds as /// Train and name every prompt, then `lower.rs`, whose `TARGET_LANGUAGE` and -/// `EntityForms` port the lowering prompt reads. Each file enters the digest +/// `EntityForms` port the lowering prompt reads, then `packet.rs`, whose serde +/// shapes the lowering prompt prints as the packet. Each file enters the digest /// behind its length, so the parts cannot be re-cut into other parts with the /// same digest. Any edit to any of them, comments included, ends a run; code /// outside those files is not covered. @@ -54,6 +55,7 @@ pub const PROMPT_SOURCE_DIGEST: u64 = fnv1a64_of(&[ include_bytes!("context.rs"), include_bytes!("split.rs"), include_bytes!("lower.rs"), + include_bytes!("packet.rs"), ]); /// The least composite gain an edit must show over item noise to be kept, and @@ -99,6 +101,43 @@ pub struct RunRecord { /// 16 hex of the bytes `create` copied to `/inventory.cc`. #[cultcache(key = 11)] pub inventory_digest: String, + /// How the run's lowering prompts are answered, set once at open. + #[cultcache(key = 12)] + pub lowerer_reach: LowererReach, +} + +/// How a run's lowering prompts reach the model that answers them (rulings +/// `lowerer-call-reach-toolless-relay`, `dao-round-seed-inventory`). The +/// record says which, so a round lowered by a briefed subagent cannot pass as +/// a tool-less one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum LowererReach { + /// A subagent briefed to read one prompt file: acceptable for smoke rounds + /// only. + BriefedSubagent, + /// The prompt passed inline to an agent with no file tools. + ToolLessRelay, +} + +impl LowererReach { + /// The spelling `weksa loop open --lowerer-reach` takes and lines print. + pub fn label(self) -> &'static str { + match self { + LowererReach::BriefedSubagent => "briefed-subagent", + LowererReach::ToolLessRelay => "tool-less-relay", + } + } +} + +impl std::str::FromStr for LowererReach { + type Err = String; + + fn from_str(s: &str) -> Result { + [LowererReach::BriefedSubagent, LowererReach::ToolLessRelay] + .into_iter() + .find(|r| r.label() == s) + .ok_or_else(|| "expected briefed-subagent or tool-less-relay".to_string()) + } } /// Where a round refused: the first stage that did. @@ -107,7 +146,8 @@ pub enum Stage { Parse, Admit, /// The lowering prompt could not be built, or its answer was not UTF-8, not - /// a `LoweringDraft`, or empty text. + /// a `LoweringDraft`, or text that is blank, over `MAX_LOWERING_BYTES` or + /// holds a control character. Lower, } @@ -355,7 +395,7 @@ impl fmt::Display for RunError { RunError::NoRun => f.write_str("the root holds no run"), RunError::Malformed(what) => write!(f, "malformed {what}"), RunError::Drift(DriftOf::Prompts) => f.write_str( - "drift in Prompts (the prompt source digest covers context.rs, split.rs and lower.rs)", + "drift in Prompts (the prompt source digest covers context.rs, split.rs, lower.rs and packet.rs)", ), RunError::Drift(of) => write!(f, "drift in {of:?}"), RunError::NoInventory => f.write_str("the inventory holds no revision"), @@ -479,8 +519,9 @@ impl RunStores { paths: RunPaths<'_>, model: &str, embedder: &str, + reach: LowererReach, ) -> Result { - Self::create_between(root, label, paths, model, embedder, || {}) + Self::create_between(root, label, paths, model, embedder, reach, || {}) } /// `create` with `between` run after the digests are taken and before the @@ -492,6 +533,7 @@ impl RunStores { paths: RunPaths<'_>, model: &str, embedder: &str, + reach: LowererReach, between: impl FnOnce(), ) -> Result { if !is_slug(label) { @@ -519,6 +561,7 @@ impl RunStores { embedder: embedder.to_string(), chrf_version: CHRF_VERSION.to_string(), inventory_digest: String::new(), + lowerer_reach: reach, }; // The run works on its own copy; the caller's store is never opened again. let head = root.join(INVENTORY_FILE); @@ -550,7 +593,7 @@ impl RunStores { } /// Reopens the run at `root` from its record, refusing when a pinned store - /// or the prompt or lowerer source no longer matches. + /// or the prompt source (`PROMPT_SOURCE_DIGEST`) no longer matches. pub fn open(root: &Path) -> Result { Self::open_between(root, || {}) } @@ -593,6 +636,18 @@ impl RunStores { &self.record } + /// `line` (a round or cycle summary) with how this run's lowerings were + /// answered; a briefed-subagent run says its numbers are smoke-round only. + pub fn labelled(&self, line: &str) -> String { + let reach = self.record.lowerer_reach; + match reach { + LowererReach::BriefedSubagent => { + format!("{line} lowering={} (smoke-round only)", reach.label()) + } + LowererReach::ToolLessRelay => format!("{line} lowering={}", reach.label()), + } + } + pub fn inventory(&self) -> &InventoryStore { &self.inventory } @@ -849,7 +904,7 @@ fn score_round( }); fates[*at] = match model.answer(&prompt) { Ok(answer) => match LoweringDraft::parse(&answer) { - Ok(draft) if !draft.text.trim().is_empty() => { + Ok(draft) if draft.usable() => { Fate::Lowered(draft.text, packet_key.clone()) } _ => Fate::Refused(Stage::Lower, Some(packet_key.clone())), @@ -858,7 +913,10 @@ fn score_round( Fate::Refused(Stage::Lower, Some(packet_key.clone())) } Err(PortError::Unanswered) => { - unanswered.push(prompt.digest()); + // Two views with one meaning share one prompt: name it once. + if !unanswered.contains(&prompt.digest()) { + unanswered.push(prompt.digest()); + } continue; } Err(PortError::Io(kind)) => { @@ -1633,6 +1691,7 @@ mod tests { embedder: String::new(), chrf_version: String::new(), inventory_digest: digest.to_string(), + lowerer_reach: LowererReach::ToolLessRelay, } } @@ -1831,7 +1890,7 @@ mod tests { entities: &entities, }, "fake-model", - "bag", + "bag", crate::run::LowererReach::ToolLessRelay, ) .unwrap(); let mut model = fake(); diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index ebefd8e..be64338 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -1344,6 +1344,8 @@ fn loop_open_and_unanswered_round() { "smoke", "--answerer", "fake-model", + "--lowerer-reach", + "tool-less-relay", ], ) }; @@ -1393,7 +1395,7 @@ fn loop_open_and_unanswered_round() { assert!(second.status.success(), "{}", out(&second)); assert_eq!( out(&second), - "round 1 rev 3 train n=1 scored=0 chrf=0.00 meaning=0.0000 refused=1 validation n=2 scored=0 chrf=0.00 meaning=0.0000 refused parse=2 admit=0 lower=0\n" + "round 1 rev 3 train n=1 scored=0 chrf=0.00 meaning=0.0000 refused=1 validation n=2 scored=0 chrf=0.00 meaning=0.0000 refused parse=2 admit=0 lower=0 lowering=tool-less-relay\n" ); // The global store flags are not read by loop round: a drifted store is refused. @@ -1420,6 +1422,8 @@ fn open_dao_run(d: &std::path::Path) { "smoke", "--answerer", "fake-model", + "--lowerer-reach", + "tool-less-relay", ], ); assert!(o.status.success(), "{}", out(&o)); @@ -1481,7 +1485,7 @@ fn loop_cycle_asks_then_decides() { assert!(done.status.success(), "{}", out(&done)); assert_eq!( out(&done), - "cycle 1 rev 3->none base=0.0000 candidate=none floor=0.0000 threshold=0.0050 rejected edit-parse\n" + "cycle 1 rev 3->none base=0.0000 candidate=none floor=0.0000 threshold=0.0050 rejected edit-parse lowering=tool-less-relay\n" ); // The next cycle asks afresh in its own directory. @@ -1520,6 +1524,8 @@ fn loop_errors_echo_no_input() { "CANARYLABEL", "--answerer", "m", + "--lowerer-reach", + "tool-less-relay", ], vec![ "loop", @@ -1529,6 +1535,8 @@ fn loop_errors_echo_no_input() { "ok", "--answerer", "CANARY\n", + "--lowerer-reach", + "tool-less-relay", ], ] { let o = weksa(d.path(), &args); @@ -1548,9 +1556,39 @@ fn loop_errors_echo_no_input() { "ok", "--answerer", "m", + "--lowerer-reach", + "tool-less-relay", ]) .output() .unwrap(); assert!(!o.status.success()); assert!(!out(&o).to_uppercase().contains("CANARY"), "{}", out(&o)); } + +#[test] +fn loop_open_requires_the_lowerer_reach_and_pins_it() { + let d = tempfile::tempdir().unwrap(); + dao_stores(d.path()); + let base = ["loop", "open", "canaryroot", "--label", "smoke", "--answerer", "m"]; + // No default: the record says how lowerings are answered, or there is no run. + let o = weksa(d.path(), &base); + assert!(!o.status.success()); + assert!(out(&o).contains("--lowerer-reach"), "{}", out(&o)); + // A value outside the two names is refused without being echoed. + let mut args = base.to_vec(); + args.extend(["--lowerer-reach", "CANARYREACH"]); + let o = weksa(d.path(), &args); + assert!(!o.status.success()); + assert!(!out(&o).to_uppercase().contains("CANARY"), "{}", out(&o)); + assert!(!d.path().join("canaryroot/run.cc").exists()); + // Either name opens, and the record keeps it. + let mut args = base.to_vec(); + args.extend(["--lowerer-reach", "briefed-subagent"]); + let o = weksa(d.path(), &args); + assert!(o.status.success(), "{}", out(&o)); + let run = weksa::run::RunStores::open(&d.path().join("canaryroot")).unwrap(); + assert_eq!( + run.record().lowerer_reach, + weksa::run::LowererReach::BriefedSubagent + ); +} diff --git a/crates/weksa/tests/common/mod.rs b/crates/weksa/tests/common/mod.rs index 3bff6d2..189202b 100644 --- a/crates/weksa/tests/common/mod.rs +++ b/crates/weksa/tests/common/mod.rs @@ -131,7 +131,7 @@ pub fn run_over(dir: &std::path::Path) -> weksa::run::RunStores { entities: &dir.join("entities.cc"), }, "fake-model", - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) .unwrap() } diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index 02adaab..58ee80d 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -47,7 +47,7 @@ fn a_run_pins_its_stores_and_refuses_drift() { entities: &e, }, model, - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) }; @@ -148,7 +148,7 @@ fn a_run_works_on_its_own_copy_of_the_inventory() { entities: &e, }, "fake-model", - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) .unwrap(); @@ -223,7 +223,7 @@ fn create_refuses_a_root_that_holds_an_inventory_and_never_truncates_it() { entities: &dao.entities, }, "fake-model", - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) }; @@ -268,7 +268,7 @@ fn a_create_that_fails_leaves_no_inventory_and_the_retry_succeeds() { entities, }, "fake-model", - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) }; // A directory opens for reading and fails mid-copy, after the destination exists. @@ -381,14 +381,14 @@ fn a_store_changed_between_its_digest_and_its_open_is_drift() { entities: &e, }; assert_eq!( - RunStores::create_between(&root, "t", paths(), "fake-model", "bag", || more(&mut w)).err(), + RunStores::create_between(&root, "t", paths(), "fake-model", "bag", weksa::run::LowererReach::ToolLessRelay, || more(&mut w)).err(), Some(RunError::Drift(DriftOf::Corpus)) ); assert!(!root.join("inventory.cc").exists()); // Not the copy and not the lock the store's open left beside it. assert!(!root.join("inventory.cc.lock").exists()); assert_eq!(RunStores::open(&root).err(), Some(RunError::NoRun)); - RunStores::create(&root, "t", paths(), "fake-model", "bag").unwrap(); + RunStores::create(&root, "t", paths(), "fake-model", "bag", weksa::run::LowererReach::ToolLessRelay).unwrap(); } /// Runs `change` in the window between the pins and the open, at `open` and at @@ -411,7 +411,7 @@ fn window( inventory: &i, entities: &e, }; - let created = RunStores::create_between(&root, "t", paths, "fake-model", "bag", || { + let created = RunStores::create_between(&root, "t", paths, "fake-model", "bag", weksa::run::LowererReach::ToolLessRelay, || { change(&mut w, &root) }) .err(); @@ -458,9 +458,14 @@ fn source_digests_cover_their_files() { let read = |name: &str| std::fs::read(src.join(name)).unwrap(); let digest = |name: &str| fnv1a64(&read(name)); // The prompt's partition and names come from split.rs, and the lowering - // prompt reads lower.rs's target language and entity port: all three are - // covered. - let (context, split, lower) = (read("context.rs"), read("split.rs"), read("lower.rs")); + // prompt reads lower.rs's target language and entity port and prints + // packet.rs's serde shapes: all four are covered. + let (context, split, lower, packet) = ( + read("context.rs"), + read("split.rs"), + read("lower.rs"), + read("packet.rs"), + ); // Each file enters behind its length, so the parts have one framing. let framed = [ (context.len() as u64).to_le_bytes().as_slice(), @@ -469,12 +474,14 @@ fn source_digests_cover_their_files() { &split, (lower.len() as u64).to_le_bytes().as_slice(), &lower, + (packet.len() as u64).to_le_bytes().as_slice(), + &packet, ] .concat(); assert_eq!(weksa::run::PROMPT_SOURCE_DIGEST, fnv1a64(&framed)); assert_ne!( weksa::run::PROMPT_SOURCE_DIGEST, - fnv1a64(&[context.clone(), split.clone(), lower.clone()].concat()), + fnv1a64(&[context.clone(), split.clone(), lower.clone(), packet.clone()].concat()), "bare concatenation is not the digest" ); // Moving the boundary between the parts moves the digest. @@ -821,7 +828,7 @@ fn dao_over(edit: impl FnOnce(&Path, &Path)) -> Dao { entities: &entities, }, "fake-model", - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) .unwrap(); Dao { @@ -1470,15 +1477,15 @@ fn errors_echo_no_input() { inventory: &missing, entities: &missing, }; - let e = RunStores::create(&root, "ok", stores, "m", "bag") + let e = RunStores::create(&root, "ok", stores, "m", "bag", weksa::run::LowererReach::ToolLessRelay) .err() .unwrap(); note(&e, &e); - let e = RunStores::create(&root, &format!("{canary}!"), stores, "m", "bag") + let e = RunStores::create(&root, &format!("{canary}!"), stores, "m", "bag", weksa::run::LowererReach::ToolLessRelay) .err() .unwrap(); note(&e, &e); - let e = RunStores::create(&root, "ok", stores, &format!("{canary}\n"), "bag") + let e = RunStores::create(&root, "ok", stores, &format!("{canary}\n"), "bag", weksa::run::LowererReach::ToolLessRelay) .err() .unwrap(); note(&e, &e); @@ -1512,7 +1519,7 @@ fn errors_echo_no_input() { entities: &dao.entities, }, "fake-model", - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) .unwrap(); let mut model = cycle_fake(&format!("{{\"{canary}\":1}}")); @@ -1570,7 +1577,7 @@ fn errors_echo_no_input() { ); // The Prompts text names the three files its digest covers. let prompts = RunError::Drift(DriftOf::Prompts).to_string(); - assert!(["context.rs", "split.rs", "lower.rs"].iter().all(|f| prompts.contains(f))); + assert!(["context.rs", "split.rs", "lower.rs", "packet.rs"].iter().all(|f| prompts.contains(f))); assert_eq!(format!("{:?}", RunError::Drift(DriftOf::Prompts)), prompts); } @@ -2442,7 +2449,7 @@ fn a_changed_head_is_drift() { entities: &dao.entities, }, "fake-model", - "bag", + "bag", weksa::run::LowererReach::ToolLessRelay, ) .unwrap(); assert_eq!( @@ -2670,6 +2677,13 @@ fn lowering_refusals_count_at_lower() { ("empty text", Ok(r#"{"text":""}"#.to_string())), ("blank text", Ok("{\"text\":\" \\n \"}".to_string())), ("non-UTF-8", Err(PortError::Io(std::io::ErrorKind::InvalidData))), + ("NUL", Ok(serde_json::json!({ "text": "The sage\u{0}acts." }).to_string())), + ("bell", Ok(serde_json::json!({ "text": "The sage \u{7}acts." }).to_string())), + ( + "over length", + Ok(serde_json::json!({ "text": "a".repeat(weksa::encode::MAX_LOWERING_BYTES + 1) }) + .to_string()), + ), ] { let r = run(raw); assert_eq!(r.train()[0].outcome, Outcome::Refused(Stage::Lower), "{what}"); @@ -2728,3 +2742,152 @@ fn an_added_entry_can_be_lowered() { Outcome::Scored { .. } )); } + +#[test] +fn a_summary_line_says_how_lowerings_were_answered() { + use weksa::run::LowererReach; + assert_eq!("briefed-subagent".parse(), Ok(LowererReach::BriefedSubagent)); + assert_eq!("tool-less-relay".parse(), Ok(LowererReach::ToolLessRelay)); + assert!("Tool-Less-Relay".parse::().is_err()); + let dao = dao(); + assert_eq!( + dao.run.labelled("round 1"), + "round 1 lowering=tool-less-relay" + ); + // A briefed-subagent run is pinned as one, in the record that reopens, and + // its lines say its numbers are smoke-round only. + let root = dao.root.join("briefed"); + let paths = RunPaths { + corpus: &dao.corpus, + inventory: &dao.inventory, + entities: &dao.entities, + }; + RunStores::create(&root, "b", paths, "fake-model", "bag", LowererReach::BriefedSubagent) + .unwrap(); + let run = RunStores::open(&root).unwrap(); + assert_eq!(run.record().lowerer_reach, LowererReach::BriefedSubagent); + assert_eq!( + run.labelled("cycle 1"), + "cycle 1 lowering=briefed-subagent (smoke-round only)" + ); +} + +#[test] +fn lowering_text_at_the_bound_is_scored_and_line_breaks_are_text() { + for text in [ + "a".repeat(weksa::encode::MAX_LOWERING_BYTES), + "The sage acts.\n\tThe sage rests.".to_string(), + ] { + let mut dao = dao(); + let mut model = fake(&good_answers()); + model.raw_lowering = Some(Ok(serde_json::json!({ "text": text }).to_string())); + let r = recorded(round(&mut dao.run, &mut model, &Scorer::new(Bag)).unwrap()); + assert_eq!( + (r.train_scored(), r.validation_scored()), + (1, 2), + "{}", + text.len() + ); + assert_eq!(r.validation_refused(), Default::default()); + } +} + +/// Two canary items, one Train and one Validation, whose speaker is an entity +/// with forms only in another language; each encodes to a packet citing it. +#[test] +fn a_lowering_prompt_that_cannot_be_built_refuses_at_lower_and_keeps_the_packet() { + use weksa::entity::{Entity, EntityOrigin, LanguageForms}; + use weksa::packet::EntityId; + let id = EntityId::parse("entity:sagexx").unwrap(); + let mut dao = dao_over(|corpus, entities| { + let mut entity_store = weksa::EntityStore::open(entities).unwrap(); + entity_store + .put(Entity { + id: EntityId::parse("entity:sagexx").unwrap(), + forms: vec![LanguageForms { + language: "sa-Latn".into(), + forms: vec!["Sagexa".into()], + pronunciation: None, + }], + origin: EntityOrigin::Cast { + persona: "sage".into(), + fixture: "f.yaml".into(), + }, + }) + .unwrap(); + let mut store = weksa::CorpusStore::open(corpus).unwrap(); + for (from, key) in [ + ("item:daodejing:1", "item:cite:1"), + ("item:daodejing:2", "item:cite:2"), + ] { + let mut item = store.get(from).unwrap().unwrap(); + item.id = key.into(); + item.text = format!("A held-apart sentence for {key}."); + item.speaker = EntityId::parse("entity:sagexx").unwrap(); + item.names = vec![]; + item.prior_turns = vec![]; + store.add(item, &entity_store).unwrap(); + } + }); + let citing = |n: usize| { + let mut d = draft_of(n); + d.referents[0].entity = Some(id.clone()); + json(&d) + }; + let mut model = fake(&good_answers()); + model.answers.insert("item:cite:1".into(), citing(1)); + model.answers.insert("item:cite:2".into(), citing(2)); + let r = recorded(round(&mut dao.run, &mut model, &Scorer::new(Bag)).unwrap()); + // Admitted, so the packet is on the Train row; refused at Lower, so no score. + let row = r.train().iter().find(|row| row.item == "item:cite:1").unwrap(); + assert_eq!(row.outcome, Outcome::Refused(Stage::Lower)); + let kept = row.packet.as_deref().expect("the admitted packet's key is kept"); + let packets = PacketStore::open(&dao.root.join("round-1/packets.cc")).unwrap(); + assert!(packets.get(kept).unwrap().is_some()); + let v = r.validation_refused(); + assert_eq!((v.parse, v.admit, v.lower), (0, 0, 1)); + assert_eq!((r.train_scored(), r.validation_scored()), (1, 2)); + // Five items encoded, three lowering prompts built and asked. + assert_eq!(r.calls().len(), 5 + 3); +} + +#[test] +fn a_port_failure_in_the_lowering_phase_discards_the_packets() { + let mut dao = dao(); + let mut model = fake(&good_answers()); + model.raw_lowering = Some(Err(PortError::Io(std::io::ErrorKind::PermissionDenied))); + assert_eq!( + round(&mut dao.run, &mut model, &Scorer::new(Bag)).err(), + Some(RunError::Io(std::io::ErrorKind::PermissionDenied)) + ); + assert!(!dao.root.join("round-1/packets.cc").exists()); + assert!(dao.run.rounds().unwrap().is_empty()); +} + +#[test] +fn views_with_one_meaning_share_one_unanswered_lowering_prompt() { + let mut dao = dao(); + // Items 1 and 3 encode to the same meaning; item 2 differs. + let same = json(&draft_of(1)); + let expected = prompts(&dao.run); + let round1 = dao.root.join("round-1"); + std::fs::create_dir_all(&round1).unwrap(); + for (key, p) in &expected { + let answer = match key.as_str() { + "item:daodejing:2" => json(&draft_of(2)), + _ => same.clone(), + }; + std::fs::write(round1.join(format!("{}.answer", p.digest())), answer).unwrap(); + } + let mut files = AnswerFiles::new(round1.clone(), "fake-model".into()); + let RoundOutcome::Unanswered { prompts: asked } = + round(&mut dao.run, &mut files, &Scorer::new(Bag)).unwrap() + else { + panic!("answered lowering from nothing") + }; + assert_eq!(asked.len(), 2, "{asked:?}"); + let mut unique = asked.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), 2); +} diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 2720c60..4846466 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -408,7 +408,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 `/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 `.prompt` and reads `.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 `/round-/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 `/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 `/cycle-/inventory.cc`, runs a candidate round under it, and `decide`s on Validation against the head's establishing round past a floor measured as the spread between rounds 1 and 2 (never under one `LADDER_STEP`). 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 inventory at the view's revision and the en-US 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`. 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). 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 inventory at the view's revision and the en-US 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`. 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. Waits on 7. diff --git a/schemas/cultnet/weksa.loop_round.schema.json b/schemas/cultnet/weksa.loop_round.schema.json index c2dee90..27205d1 100644 --- a/schemas/cultnet/weksa.loop_round.schema.json +++ b/schemas/cultnet/weksa.loop_round.schema.json @@ -228,7 +228,7 @@ ] }, { - "description": "The lowering prompt could not be built, or its answer was not UTF-8, not\na `LoweringDraft`, or empty text.", + "description": "The lowering prompt could not be built, or its answer was not UTF-8, not\na `LoweringDraft`, or text that is blank, over `MAX_LOWERING_BYTES` or\nholds a control character.", "type": "string", "const": "Lower" } diff --git a/schemas/cultnet/weksa.loop_run.schema.json b/schemas/cultnet/weksa.loop_run.schema.json index 8473bac..b5b5ab8 100644 --- a/schemas/cultnet/weksa.loop_run.schema.json +++ b/schemas/cultnet/weksa.loop_run.schema.json @@ -36,6 +36,10 @@ "label": { "type": "string" }, + "lowerer_reach": { + "description": "How the run's lowering prompts are answered, set once at open.", + "$ref": "#/$defs/LowererReach" + }, "model": { "description": "The answering model.", "type": "string" @@ -55,6 +59,24 @@ "model", "embedder", "chrf_version", - "inventory_digest" - ] + "inventory_digest", + "lowerer_reach" + ], + "$defs": { + "LowererReach": { + "description": "How a run's lowering prompts reach the model that answers them (rulings\n`lowerer-call-reach-toolless-relay`, `dao-round-seed-inventory`). The\nrecord says which, so a round lowered by a briefed subagent cannot pass as\na tool-less one.", + "oneOf": [ + { + "description": "A subagent briefed to read one prompt file: acceptable for smoke rounds\nonly.", + "type": "string", + "const": "BriefedSubagent" + }, + { + "description": "The prompt passed inline to an agent with no file tools.", + "type": "string", + "const": "ToolLessRelay" + } + ] + } + } }