From 3b7a6803bc1f9c3ab91c55ac0eb4ddec1b4355c3 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 09:10:56 +0000 Subject: [PATCH 1/3] Retire the lowerer source pin: one prompt pin over context, split and lower Deletes LOWERER_SOURCE_DIGEST, DriftOf::Lowerer, RunRecord key 7 and RoundRecord key 3 (retired, never reused). PROMPT_SOURCE_DIGEST now covers lower.rs, which the lowering prompt will read. Schemas regenerated. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/run.rs | 40 +++++++------------- crates/weksa/tests/golf_loop.rs | 30 ++++++--------- schemas/cultnet/weksa.loop_round.schema.json | 6 +-- schemas/cultnet/weksa.loop_run.schema.json | 4 -- 4 files changed, 25 insertions(+), 55 deletions(-) diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index 1efe18e..0f122ef 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -40,16 +40,16 @@ use crate::store::{CorpusStore, EntityStore, InventoryStore, PacketStore, open_c /// 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. Each file enters the digest behind its length, -/// so the pair cannot be re-cut into another pair with the same digest. Any -/// edit to either, comments included, ends a run; code outside those files is -/// not covered. -pub const PROMPT_SOURCE_DIGEST: u64 = - fnv1a64_of(&[include_bytes!("context.rs"), include_bytes!("split.rs")]); - -/// The digest of the lowerer's source. Any edit to `lower.rs`, comments -/// included, ends a run; code outside that file is not covered. -pub const LOWERER_SOURCE_DIGEST: u64 = fnv1a64(include_bytes!("lower.rs")); +/// Train and name every prompt, then `lower.rs`, whose `TARGET_LANGUAGE` and +/// `EntityForms` port the lowering prompt reads. 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. +pub const PROMPT_SOURCE_DIGEST: u64 = fnv1a64_of(&[ + include_bytes!("context.rs"), + include_bytes!("split.rs"), + include_bytes!("lower.rs"), +]); /// The least composite gain an edit must show over item noise to be kept, and /// the grid the editor's one held-out number is rounded to. A run releases at @@ -81,8 +81,8 @@ pub struct RunRecord { pub entity_digest: String, #[cultcache(key = 6)] pub prompt_digest: String, - #[cultcache(key = 7)] - pub lowerer_digest: String, + // Key 7 is retired and never reused: a run.cc from before is a scratch run + // off git and does not open. /// The answering model. #[cultcache(key = 8)] pub model: String, @@ -151,7 +151,6 @@ pub struct RefusalCounts { /// n: 1, /// inventory_rev: 1, /// prompt_digest: String::new(), -/// lowerer_digest: String::new(), /// corpus_digest: String::new(), /// entity_digest: String::new(), /// model: String::new(), @@ -178,8 +177,7 @@ pub struct RoundRecord { inventory_rev: u32, #[cultcache(key = 2)] prompt_digest: String, - #[cultcache(key = 3)] - lowerer_digest: String, + // Key 3 is retired and never reused. #[cultcache(key = 4)] corpus_digest: String, #[cultcache(key = 5)] @@ -227,9 +225,6 @@ impl RoundRecord { pub fn prompt_digest(&self) -> &str { &self.prompt_digest } - pub fn lowerer_digest(&self) -> &str { - &self.lowerer_digest - } pub fn corpus_digest(&self) -> &str { &self.corpus_digest } @@ -314,7 +309,6 @@ pub enum DriftOf { Corpus, Entities, Prompts, - Lowerer, Model, Embedder, /// The run's own head inventory file. @@ -512,7 +506,6 @@ impl RunStores { corpus_digest: String::new(), entity_digest: String::new(), prompt_digest: hex16(PROMPT_SOURCE_DIGEST), - lowerer_digest: hex16(LOWERER_SOURCE_DIGEST), model: model.to_string(), embedder: embedder.to_string(), chrf_version: CHRF_VERSION.to_string(), @@ -565,9 +558,6 @@ impl RunStores { if record.prompt_digest != hex16(PROMPT_SOURCE_DIGEST) { return Err(RunError::Drift(DriftOf::Prompts)); } - if record.lowerer_digest != hex16(LOWERER_SOURCE_DIGEST) { - return Err(RunError::Drift(DriftOf::Lowerer)); - } check_pins(&cache, root, &record)?; between(); let run = Self::with(root, cache, record)?; @@ -913,7 +903,6 @@ fn score_round( n, inventory_rev: rev, prompt_digest: run.record.prompt_digest.clone(), - lowerer_digest: run.record.lowerer_digest.clone(), corpus_digest: run.record.corpus_digest.clone(), entity_digest: run.record.entity_digest.clone(), model: run.record.model.clone(), @@ -1346,7 +1335,6 @@ mod tests { n: 1, inventory_rev: 1, prompt_digest: String::new(), - lowerer_digest: String::new(), corpus_digest: String::new(), entity_digest: String::new(), model: String::new(), @@ -1558,7 +1546,6 @@ mod tests { n, inventory_rev: 1, prompt_digest: String::new(), - lowerer_digest: String::new(), corpus_digest: String::new(), entity_digest: String::new(), model: String::new(), @@ -1589,7 +1576,6 @@ mod tests { corpus_digest: String::new(), entity_digest: String::new(), prompt_digest: String::new(), - lowerer_digest: String::new(), model: String::new(), embedder: String::new(), chrf_version: String::new(), diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index a044538..b40a260 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -108,10 +108,6 @@ fn open_checks_each_pin_by_name() { drift(&|r| r.prompt_digest = "0".repeat(16)), Some(RunError::Drift(DriftOf::Prompts)) ); - assert_eq!( - drift(&|r| r.lowerer_digest = "0".repeat(16)), - Some(RunError::Drift(DriftOf::Lowerer)) - ); assert_eq!( drift(&|r| r.corpus_digest = "0".repeat(16)), Some(RunError::Drift(DriftOf::Corpus)) @@ -339,7 +335,7 @@ fn a_run_from_before_the_copy_does_not_open() { corpus_digest: current.corpus_digest, entity_digest: current.entity_digest, prompt_digest: current.prompt_digest, - lowerer_digest: current.lowerer_digest, + lowerer_digest: String::new(), model: current.model, embedder: current.embedder, chrf_version: current.chrf_version, @@ -459,24 +455,26 @@ fn a_head_inventory_changed_between_its_digest_and_its_open_is_drift() { #[test] fn source_digests_cover_their_files() { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let digest = |name: &str| fnv1a64(&std::fs::read(src.join(name)).unwrap()); - // The prompt's partition and names come from split.rs: it is covered too. - let (context, split) = ( - std::fs::read(src.join("context.rs")).unwrap(), - std::fs::read(src.join("split.rs")).unwrap(), - ); - // Each file enters behind its length, so the pair has one framing. + 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")); + // Each file enters behind its length, so the parts have one framing. let framed = [ (context.len() as u64).to_le_bytes().as_slice(), &context, (split.len() as u64).to_le_bytes().as_slice(), &split, + (lower.len() as u64).to_le_bytes().as_slice(), + &lower, ] .concat(); assert_eq!(weksa::run::PROMPT_SOURCE_DIGEST, fnv1a64(&framed)); assert_ne!( weksa::run::PROMPT_SOURCE_DIGEST, - fnv1a64(&[context.clone(), split.clone()].concat()), + fnv1a64(&[context.clone(), split.clone(), lower.clone()].concat()), "bare concatenation is not the digest" ); // Moving the boundary between the parts moves the digest. @@ -485,11 +483,6 @@ fn source_digests_cover_their_files() { weksa::split::fnv1a64_of(&[b"a", b"bc"]) ); assert_ne!(weksa::run::PROMPT_SOURCE_DIGEST, digest("context.rs")); - assert_eq!(weksa::run::LOWERER_SOURCE_DIGEST, digest("lower.rs")); - assert_ne!( - weksa::run::PROMPT_SOURCE_DIGEST, - weksa::run::LOWERER_SOURCE_DIGEST - ); } /// The encode prompt of `item:nibu:1` over `world()` at revision 4. @@ -860,7 +853,6 @@ fn round_over_the_dao_stores_scores_train_and_validation() { // The record carries the run's pins. assert_eq!(r.prompt_digest(), pinned.prompt_digest); - assert_eq!(r.lowerer_digest(), pinned.lowerer_digest); assert_eq!(r.corpus_digest(), pinned.corpus_digest); assert_eq!(r.entity_digest(), pinned.entity_digest); assert_eq!(r.model(), "fake-model"); diff --git a/schemas/cultnet/weksa.loop_round.schema.json b/schemas/cultnet/weksa.loop_round.schema.json index d846e12..ec4111a 100644 --- a/schemas/cultnet/weksa.loop_round.schema.json +++ b/schemas/cultnet/weksa.loop_round.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "RoundRecord", - "description": "One scored round. It holds no text and no Test reading: per-item rows for\nTrain only, Validation only as means and refusal counts by stage. Only\n`run::round` builds one.\n\n```\nfn f(r: &weksa::run::RoundRecord) -> u32 { r.n() }\n```\n\nEvery field is given, so the literal below fails only on privacy; its twin\nis the same literal in the unit test `a_round_record_literal_compiles_in_the_lib`.\n\n```compile_fail,E0451\nuse weksa::run::{RefusalCounts, RoundRecord};\nuse weksa::score::SplitMean;\nfn f() -> RoundRecord {\n RoundRecord {\n n: 1,\n inventory_rev: 1,\n prompt_digest: String::new(),\n lowerer_digest: String::new(),\n corpus_digest: String::new(),\n entity_digest: String::new(),\n model: String::new(),\n embedder: String::new(),\n chrf_version: String::new(),\n calls: vec![],\n train: vec![],\n train_mean: SplitMean::default(),\n train_scored: 0,\n validation_mean: SplitMean::default(),\n validation_scored: 0,\n validation_refused: RefusalCounts::default(),\n inventory_digest: String::new(),\n cycle: None,\n }\n}\n```", + "description": "One scored round. It holds no text and no Test reading: per-item rows for\nTrain only, Validation only as means and refusal counts by stage. Only\n`run::round` builds one.\n\n```\nfn f(r: &weksa::run::RoundRecord) -> u32 { r.n() }\n```\n\nEvery field is given, so the literal below fails only on privacy; its twin\nis the same literal in the unit test `a_round_record_literal_compiles_in_the_lib`.\n\n```compile_fail,E0451\nuse weksa::run::{RefusalCounts, RoundRecord};\nuse weksa::score::SplitMean;\nfn f() -> RoundRecord {\n RoundRecord {\n n: 1,\n inventory_rev: 1,\n prompt_digest: String::new(),\n corpus_digest: String::new(),\n entity_digest: String::new(),\n model: String::new(),\n embedder: String::new(),\n chrf_version: String::new(),\n calls: vec![],\n train: vec![],\n train_mean: SplitMean::default(),\n train_scored: 0,\n validation_mean: SplitMean::default(),\n validation_scored: 0,\n validation_refused: RefusalCounts::default(),\n inventory_digest: String::new(),\n cycle: None,\n }\n}\n```", "type": "object", "properties": { "calls": { @@ -40,9 +40,6 @@ "format": "uint32", "minimum": 0 }, - "lowerer_digest": { - "type": "string" - }, "model": { "type": "string" }, @@ -86,7 +83,6 @@ "n", "inventory_rev", "prompt_digest", - "lowerer_digest", "corpus_digest", "entity_digest", "model", diff --git a/schemas/cultnet/weksa.loop_run.schema.json b/schemas/cultnet/weksa.loop_run.schema.json index 69bb0a4..8473bac 100644 --- a/schemas/cultnet/weksa.loop_run.schema.json +++ b/schemas/cultnet/weksa.loop_run.schema.json @@ -36,9 +36,6 @@ "label": { "type": "string" }, - "lowerer_digest": { - "type": "string" - }, "model": { "description": "The answering model.", "type": "string" @@ -55,7 +52,6 @@ "corpus_digest", "entity_digest", "prompt_digest", - "lowerer_digest", "model", "embedder", "chrf_version", From 74ebafbc65a04a51deaa104c0ab93439dec306b3 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 09:18:38 +0000 Subject: [PATCH 2/3] The round lowers through the ModelPort: Prompt::lower over a LoweringInput, two answer phases run::score_round no longer calls lower::lower. Prompt::lower prints the meaning of an admitted view, en-US entity forms and the inventory at the view's revision, and no item text, key, group, speaker or packet key. The answer is a LoweringDraft {text}, held in score_round's locals and scored, never written to run.cc, a RoundRecord, a Decision or EditFeedback. A round or cycle asks every encode prompt, then every lowering prompt, all or nothing. An entry an edit adds can now be lowered. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 8 +- crates/weksa/src/context.rs | 97 ++++- crates/weksa/src/encode.rs | 40 +- crates/weksa/src/lib.rs | 2 +- crates/weksa/src/lower.rs | 9 +- crates/weksa/src/main.rs | 11 +- crates/weksa/src/run.rs | 85 +++- crates/weksa/tests/context.rs | 147 +++++++ crates/weksa/tests/golf_loop.rs | 394 +++++++++++++++++-- docs/targets/interlingua-lowering-map.md | 8 +- schemas/cultnet/weksa.loop_round.schema.json | 19 +- 11 files changed, 748 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index e9aa0e9..3133190 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ measured. - `settings/`: setting-specific proving grounds for language projects. **Live versus planned.** Live: docs, fixtures, examples, ontology data and the -packet crate `crates/weksa`, including lowering, `weksa lower` and `weksa score`, -and the golf loop's round and edit-accept cycle (`weksa loop open|round|cycle`). -Planned: a model lowerer, Score documents and the other panel languages. No +packet crate `crates/weksa`, including `weksa lower` (the template lowerer for +proof fixtures) and `weksa score`, and the golf loop's round and edit-accept +cycle (`weksa loop open|round|cycle`), which encodes and lowers through the +model (a prompted Claude subagent, per staged-distill). +Planned: Score and lowering documents and the other panel languages. No daemon runs; the earlier Node daemon is parked at tag `parked/weksa-node-body`. ## Ownership Model diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index 6205a61..b162e6d 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -1,12 +1,14 @@ -//! The one place prompt text is built from corpus text. Corpus text reaches a -//! model call only as a [`Prompt`], and a `Prompt` is made only by minting from -//! [`TrainItems`] or by encoding one item an [`EncoderOffer`] holds: Train-only -//! minting and per-item encoding (leak-owned-by-flow-and-scorer-3, -//! loop-context-builder-2). Instruction text is [`MINT_INSTRUCTIONS`] and -//! [`ENCODE_INSTRUCTIONS`], consts in this module; both prompts also list the -//! inventory at the run's revision. A mint prompt is the editor's: it reads -//! [`TrainItems`], an [`Inventory`] and an [`EditFeedback`] and nothing else, so -//! no held-out text, key or per-item score can reach it. +//! The one place prompt text is built. Corpus text reaches a model call only as +//! a [`Prompt`], and a `Prompt` is made only by minting from [`TrainItems`], by +//! encoding one item an [`EncoderOffer`] holds, or by lowering one +//! [`LoweringInput`], which holds no corpus text, item key, group or speaker: +//! Train-only minting, per-item encoding and text-free lowering +//! (leak-owned-by-flow-and-scorer-3, loop-context-builder-2). Instruction text +//! is [`MINT_INSTRUCTIONS`], [`ENCODE_INSTRUCTIONS`] and [`LOWER_INSTRUCTIONS`], +//! consts in this module; every prompt also lists the inventory at the run's +//! revision. A mint prompt is the editor's: it reads [`TrainItems`], an +//! [`Inventory`] and an [`EditFeedback`] and nothing else, so no held-out text, +//! key or per-item score can reach it. //! //! Item text may hold line breaks; the renderer indents every continuation line //! (`indented`), so item text cannot forge a `[key]` line or a paragraph. @@ -16,14 +18,21 @@ //! run with a different corpus digest, visible in its record; nothing inside a //! process its forger controls can stop it. +use std::collections::BTreeMap; use std::marker::PhantomData; -use crate::corpus::CorpusItem; +use serde::Serialize; + +use crate::corpus::{CorpusItem, CorpusItemId}; use crate::inventory::{Inventory, InventoryEntry, OptionLabel, ValueKind}; -use crate::packet::EntityId; +use crate::lower::{EntityForms, LowerError, TARGET_LANGUAGE, entity}; +use crate::packet::{ + Context, Discourse, EntityId, Extension, Predication, Referent, Relation, Stance, +}; use crate::run::LADDER_STEP; use crate::split::{Checkpoint, Split, SplitScheme, fnv1a64, hex16}; use crate::store::{CorpusStore, normalized}; +use crate::validate::LoweringInput; /// The instructions a mint prompt starts with: the editor's whole brief. Fixed /// per run by `PROMPT_SOURCE_DIGEST`. @@ -53,6 +62,14 @@ Fields: Node ids are n1, n2, and so on, one per referent and predication. Every other id comes from the inventory listed below and must be live at its revision. An entity comes only from the entities line. A feature is {feature, value}; value is {\"Grade\": 0 to 1000}, {\"Option\": label} or {\"Flag\": true or false}, as the inventory lists the feature's kind."; +/// The instructions a lowering prompt starts with: the lowerer's whole brief. +/// Fixed per run by `PROMPT_SOURCE_DIGEST`. +pub const LOWER_INSTRUCTIONS: &str = "\ +Say the packet below in English (en-US), as one utterance of its speaker: all of its meaning and nothing it does not carry. +Every id is the inventory's listed below and every feature value is as listed. The stance is the speaker's felt affect and restraint: it shapes how the utterance is said, not what it says. +A referent with an entity is named by one of that entity's forms on the entities line. +Answer with one JSON object and nothing else: {\"text\": \"...\"}. No code fence, no commentary."; + /// The live Train items of a corpus, in key order. The only input minting /// reads; it cannot be built from a slice, and no accessor returns text or an /// item. @@ -286,7 +303,7 @@ impl EditFeedback { } /// Text for a model call, and the keys of the items it holds. The only -/// constructors are [`Prompt::mint`] and [`Prompt::encode`]. +/// constructors are [`Prompt::mint`], [`Prompt::encode`] and [`Prompt::lower`]. /// /// ``` /// # let dir = tempfile::tempdir().unwrap(); @@ -418,6 +435,20 @@ fn inventory_lines(inventory: &Inventory, rev: u32) -> String { .collect() } +/// The meaning a lowering prompt prints: the view's fields minus kind and +/// constraints, which are not meaning, and scene, speaker and group, which +/// are not in the packet. +#[derive(Serialize)] +struct Meaning<'a> { + context: &'a Context, + discourse: &'a Discourse, + referents: &'a [Referent], + predications: &'a [Predication], + relations: &'a [Relation], + extensions: &'a [Extension], + stance: &'a Stance, +} + /// Printed in place of a prior turn whose text no live Train item holds. const ELIDED: &str = "[elided]"; @@ -464,6 +495,48 @@ 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. + pub fn lower( + input: &LoweringInput, + item: &CorpusItemId, + inventory: &Inventory, + entities: &dyn EntityForms, + ) -> Result { + let mut forms: BTreeMap<&str, Vec> = BTreeMap::new(); + for id in input.referents().iter().filter_map(|r| r.entity.as_ref()) { + let found = entity(entities, id)?; + let named = found + .forms_in(TARGET_LANGUAGE) + .filter(|f| !f.is_empty()) + .ok_or_else(|| LowerError::NoForm { entity: id.clone() })?; + forms.insert(id.as_str(), named.to_vec()); + } + let meaning = Meaning { + context: input.context(), + discourse: input.discourse(), + referents: input.referents(), + predications: input.predications(), + relations: input.relations(), + extensions: input.extensions(), + stance: input.stance(), + }; + let rev = input.inventory_rev(); + let text = format!( + "{LOWER_INSTRUCTIONS}\n\npacket: {}\n\nentities: {}\n\ninventory rev:{rev}{}", + serde_json::to_string(&meaning).expect("a meaning is plain data"), + serde_json::to_string(&forms).expect("forms are plain data"), + inventory_lines(inventory, rev) + ); + Ok(Prompt { + text, + items: vec![item.as_str().to_string()], + }) + } + /// 16 lowercase hex digits of FNV-1a 64 of the text: the name of the prompt /// in files and records. pub fn digest(&self) -> String { diff --git a/crates/weksa/src/encode.rs b/crates/weksa/src/encode.rs index c8b37a2..0a0d358 100644 --- a/crates/weksa/src/encode.rs +++ b/crates/weksa/src/encode.rs @@ -1,7 +1,8 @@ -//! The encoder and editor steps: what a model's answer becomes. This is the one -//! place JSON is read (the xenos boundary): a model answers in JSON, -//! `PacketDraft::parse` or `EditDraft::parse` reads it (both through -//! `from_json`), and nothing is stored as JSON. The encoder writes meaning +//! The encoder, editor and lowerer steps: what a model's answer becomes. This is +//! the one place JSON is read (the xenos boundary): a model answers in JSON, +//! `PacketDraft::parse`, `EditDraft::parse` or `LoweringDraft::parse` reads it +//! (all through `from_json`), and nothing is stored as JSON. The lowerer writes +//! `text` only. The encoder writes meaning //! only; `into_packet` sets the id, schema version, inventory revision, kind, //! provenance, constraints and extensions, and an empty trace, so a model that //! saw a Validation item cannot hand its words to whoever reads packets. The @@ -199,6 +200,19 @@ impl EditDraft { } } +/// A lowering as the lowerer writes it: the English text, nothing else. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoweringDraft { + pub text: String, +} + +impl LoweringDraft { + pub fn parse(answer: &str) -> Result { + from_json(answer) + } +} + /// `encoder:`, a digest of the model id and the prompt digest. pub fn encoder_id(model: &str, prompt_digest: &str) -> EncoderId { let digest = fnv1a64(format!("weksa-encoder-v1:{model}:{prompt_digest}").as_bytes()); @@ -273,6 +287,24 @@ mod tests { assert_eq!(PacketDraft::of(&made), draft); } + #[test] + fn lowering_draft_parse() { + let draft = LoweringDraft::parse("\n {\"text\":\"a\"} \n").unwrap(); + assert_eq!(draft.text, "a"); + for bad in [ + r#"{"text":"a","trace":"b"}"#, + r#"{}"#, + r#"{"text":1}"#, + "the sage acts", + "", + ] { + let err = LoweringDraft::parse(bad).unwrap_err(); + // Position only: no answer bytes in either rendering. + let shown = format!("{err} {err:?}"); + assert!(!shown.contains("sage") && !shown.contains("trace"), "{shown}"); + } + } + #[test] fn parse_reads_a_draft_and_trims() { let draft = PacketDraft::of(&packets()[0]); diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index c741c62..efea9ef 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -23,7 +23,7 @@ pub mod store; pub mod validate; pub use corpus::{CorpusItem, CorpusItemId, NameSpan, Source, Turn}; -pub use encode::{EditDraft, PacketDraft}; +pub use encode::{EditDraft, LoweringDraft, PacketDraft}; pub use entity::{Entity, EntityOrigin, LanguageForms, Pronunciation}; pub use form::FormError; pub use inventory::{ diff --git a/crates/weksa/src/lower.rs b/crates/weksa/src/lower.rs index 9f90239..e8e1ea9 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -1,5 +1,8 @@ -//! The English lowerer: the validated lowering view of a packet to text, by a -//! fixed table keyed by inventory ids. It renders predications, referents and the +//! The English template lowerer: the validated lowering view of a packet to +//! text, by a fixed table keyed by inventory ids. It is the deterministic +//! lowerer of `weksa lower`, the proof fixtures and their tests; the golf loop +//! lowers through the `ModelPort` (`run.rs`, `Prompt::lower`), and follow-up +//! `table-retirement` decides this table's future. It renders predications, referents and the //! speech act, and nothing else: no style, no model, no corpus text. It takes a //! [`LoweringInput`], so it cannot see the trace or the corpus item. A packet //! carrying a feature, relation, extension, role fill or referent it does not @@ -497,7 +500,7 @@ fn name(entities: &dyn EntityForms, id: &EntityId) -> Result .ok_or_else(|| LowerError::NoForm { entity: id.clone() }) } -fn entity(entities: &dyn EntityForms, id: &EntityId) -> Result { +pub(crate) fn entity(entities: &dyn EntityForms, id: &EntityId) -> Result { entities .entity(id) .map_err(|e| LowerError::EntityRead { diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index f8a528b..d3e9f35 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -91,17 +91,18 @@ enum LoopCommand { }, /// 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 - /// read. Prompts without answers are written to the round's directory and - /// the command exits 3; rerun after the `.answer` files exist. + /// read. Encode prompts are written first; once they are answered a rerun + /// writes the lowering prompts. The command exits 3 each time until every + /// prompt has an answer. Round { root: PathBuf, #[arg(long, default_value = "http://127.0.0.1:11434")] ollama_url: String, }, /// Run one edit-accept cycle of the run at ROOT. The stores are read only - /// from the run's record. Prompts without answers are written to the - /// cycle's directory and the command exits 3; rerun after the `.answer` - /// files exist. + /// from the run's record. The editor's prompt, then the candidate round's + /// encode prompts, then its lowering prompts are written in turn; the + /// command exits 3 each time until every prompt has an answer. Cycle { root: PathBuf, #[arg(long, default_value = "http://127.0.0.1:11434")] diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs index 0f122ef..3e511f2 100644 --- a/crates/weksa/src/run.rs +++ b/crates/weksa/src/run.rs @@ -17,6 +17,11 @@ //! later reader compares the rounds of one run only. `RunStores` is the one //! public producer of [`TrainItems`] and [`EncoderOffer`]: the CLI paths that //! take caller stores (`validate`, `packet proof|dao`, `lower`) are unchanged. +//! +//! A round encodes, admits, then lowers each admitted view through the +//! `ModelPort` (`Prompt::lower`), in two answer phases; the CLI's `lower` still +//! uses the template table. The prompt source pin covers `context.rs`, +//! `split.rs` and `lower.rs`. use std::fmt; use std::io::ErrorKind; @@ -30,13 +35,13 @@ use serde::{Deserialize, Serialize}; use crate::context::{EditFeedback, EncoderOffer, Prompt, TrainItems}; use crate::corpus::{CorpusItem, CorpusItemId}; -use crate::encode::{EditDraft, PacketDraft, encoder_id}; +use crate::encode::{EditDraft, LoweringDraft, PacketDraft, encoder_id}; use crate::form::{is_attestation, is_slug}; -use crate::lower::lower; use crate::model::{ModelPort, PortError}; use crate::score::{CHRF_VERSION, Embedder, ItemScore, ScoreInput, Scorer, SplitMean, aggregate}; use crate::split::{Split, SplitScheme, fnv1a64, fnv1a64_of, hex16}; use crate::store::{CorpusStore, EntityStore, InventoryStore, PacketStore, open_cache_over}; +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 @@ -101,6 +106,8 @@ pub struct RunRecord { 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. Lower, } @@ -188,6 +195,8 @@ pub struct RoundRecord { embedder: String, #[cultcache(key = 8)] chrf_version: String, + /// The encode calls, then the lowering calls, each with its prompt digest + /// and one item key. #[cultcache(key = 9)] calls: Vec, #[cultcache(key = 10)] @@ -346,7 +355,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 and split.rs)", + "drift in Prompts (the prompt source digest covers context.rs, split.rs and lower.rs)", ), RunError::Drift(of) => write!(f, "drift in {of:?}"), RunError::NoInventory => f.write_str("the inventory holds no revision"), @@ -707,10 +716,12 @@ enum Fate { /// One round: encodes every live Train and Validation item of the run's pinned /// corpus through `model`, admits each packet into the round's own -/// `/round-/packets.cc`, lowers, scores, and records the round. The -/// round is all or nothing: while any prompt is unanswered, nothing is written, -/// and a round that fails before its record is put (the embedder down) leaves no -/// `packets.cc` behind, so a rerun with revised answers starts clean. A mean +/// `/round-/packets.cc`, lowers each admitted view through the same +/// port, scores, and records the round. The round has two answer phases, all +/// or nothing: while any encode or lowering prompt is unanswered, nothing +/// reaches `run.cc` and the round directory keeps no `packets.cc`. A round that +/// fails before its record is put (the embedder down) leaves no `packets.cc` +/// behind, so a rerun with revised answers starts clean. A mean /// counts an item that was refused or did not lower as 0, so refusing a weak /// item cannot raise it; `scored` counts beside it say how many were real. pub fn round( @@ -800,6 +811,8 @@ fn score_round( let encoder = encoder_id(model.model(), &run.record.prompt_digest); let mut items: Vec = Vec::new(); let mut fates: Vec = Vec::new(); + // Phase 1 ends at admission: each admitted view waits for its lowering. + let mut admitted: Vec<(usize, CorpusItemId, String, LoweringInput)> = Vec::new(); for (key, answer) in &answers { let item = run.corpus.get(key).map_err(store)?.ok_or(RunError::Store)?; let id = CorpusItemId::parse(key).map_err(|_| RunError::Store)?; @@ -810,10 +823,11 @@ fn score_round( let packet_key = packet.id.to_string(); match packets.admit(packet, inventory_store, &run.corpus, &run.entities) { Err(_) => Fate::Refused(Stage::Admit, None), - Ok(view) => match lower(&view, &inventory, &run.entities) { - Err(_) => Fate::Refused(Stage::Lower, Some(packet_key)), - Ok(text) => Fate::Lowered(text, packet_key), - }, + Ok(view) => { + admitted.push((fates.len(), id, packet_key, view)); + // Replaced below by what the lowering answers. + Fate::Refused(Stage::Lower, None) + } } } }; @@ -821,6 +835,45 @@ fn score_round( fates.push(fate); } + // Phase 2: one lowering prompt per admitted view, answered through the + // port. Nothing is kept unless every one is answered. + let mut unanswered = Vec::new(); + for (at, id, packet_key, view) in &admitted { + let Ok(prompt) = Prompt::lower(view, id, &inventory, &run.entities) else { + fates[*at] = Fate::Refused(Stage::Lower, Some(packet_key.clone())); + continue; + }; + calls.push(Call { + prompt: prompt.digest(), + items: prompt.item_keys().to_vec(), + }); + fates[*at] = match model.answer(&prompt) { + Ok(answer) => match LoweringDraft::parse(&answer) { + Ok(draft) if !draft.text.trim().is_empty() => { + Fate::Lowered(draft.text, packet_key.clone()) + } + _ => Fate::Refused(Stage::Lower, Some(packet_key.clone())), + }, + Err(PortError::Io(ErrorKind::InvalidData)) => { + Fate::Refused(Stage::Lower, Some(packet_key.clone())) + } + Err(PortError::Unanswered) => { + unanswered.push(prompt.digest()); + continue; + } + Err(PortError::Io(kind)) => { + discard_packets(dir); + return Err(RunError::Io(kind)); + } + }; + } + if !unanswered.is_empty() { + discard_packets(dir); + return Ok(RoundOutcome::Unanswered { + prompts: unanswered, + }); + } + // One score call over what lowered; none when nothing did. let lowered: Vec<(usize, &str)> = fates .iter() @@ -1686,6 +1739,16 @@ mod tests { .to_string(), ); } + if prompt.text().starts_with(crate::context::LOWER_INSTRUCTIONS) { + // The table's English for the Dao items, the lowerer's answer. + let text = match prompt.item_keys()[0].as_str() { + "item:daodejing:1" => "The sage empties the minds. The sage fills the bellies. The sage weakens the wills. The sage strengthens the bones.", + "item:daodejing:2" => "The heaven is long-enduring. The earth continues long.", + "item:daodejing:3" => "The highest excellence is like the water.", + other => panic!("lowering asked about {other}"), + }; + return Ok(format!(r#"{{"text":"{text}"}}"#)); + } let answers = if prompt.text().contains("\n\ninventory rev:4\n") { &self.good } else { diff --git a/crates/weksa/tests/context.rs b/crates/weksa/tests/context.rs index c1784d8..fe351d7 100644 --- a/crates/weksa/tests/context.rs +++ b/crates/weksa/tests/context.rs @@ -539,3 +539,150 @@ fn mint_and_encode_list_the_same_inventory() { }; assert_ne!(at(2), at(4), "the revision selects the entries"); } + +mod lowering { + use std::path::{Path, PathBuf}; + + use weksa::context::{LOWER_INSTRUCTIONS as LOWER, Prompt}; + use weksa::daodejing::packets; + use weksa::entity::{Entity, EntityOrigin, LanguageForms}; + use weksa::packet::{EntityId, Packet, proof_packet}; + use weksa::{ + CorpusItemId, CorpusStore, EntityForms, EntityStore, Inventory, InventoryStore, + LowerError, LoweringInput, PacketStore, + }; + + fn data(sub: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data").join(sub) + } + + fn admitted( + packet: Packet, + inv: &InventoryStore, + corpus: &str, + entities: &EntityStore, + ) -> LoweringInput { + let corpus = CorpusStore::open(&data(corpus)).unwrap(); + let dir = tempfile::tempdir().unwrap(); + PacketStore::open(&dir.path().join("p.cc")) + .unwrap() + .admit(packet, inv, &corpus, entities) + .unwrap() + } + + fn dao(index: usize) -> (LoweringInput, Inventory, EntityStore, CorpusItemId) { + let inv = InventoryStore::open(&data("daodejing/inventory.cc")).unwrap(); + let entities = EntityStore::open(&data("daodejing/entity.cc")).unwrap(); + let packet = packets().remove(index); + let item = packet.provenance.corpus_item.clone(); + let view = admitted(packet, &inv, "daodejing/corpus.cc", &entities); + (view, inv.inventory().unwrap(), entities, item) + } + + /// An entity port a test controls. + struct Held(Vec); + + impl EntityForms for Held { + fn entity(&self, id: &EntityId) -> anyhow::Result> { + Ok(self.0.iter().find(|e| &e.id == id).cloned()) + } + } + + fn nibu(forms: &[(&str, &[&str])]) -> Entity { + Entity { + id: EntityId::parse("entity:nibu").unwrap(), + forms: forms + .iter() + .map(|(language, forms)| LanguageForms { + language: (*language).into(), + forms: forms.iter().map(|f| (*f).into()).collect(), + pronunciation: None, + }) + .collect(), + origin: EntityOrigin::Cast { + persona: "nibu".into(), + fixture: "f.yaml".into(), + }, + } + } + + #[test] + fn lower_prompt_text_is_pinned() { + let (view, inv, entities, item) = dao(2); + let prompt = Prompt::lower(&view, &item, &inv, &entities).unwrap(); + let (head, listing) = prompt.text().split_once("inventory rev:3\n").unwrap(); + let expected = format!( + "{LOWER}\n\npacket: {}\n\nentities: {{}}\n\n", + concat!( + r#"{"context":{"setting":null,"medium":null,"participants":[],"audience":[]},"#, + r#""discourse":{"speech_act":"speech_act:1","addressee":null,"addressee_consent":null,"features":[]},"#, + r#""referents":[{"id":"n1","kind":"referent_kind:11","concept":"concept:20","entity":null,"features":[]},"#, + r#"{"id":"n2","kind":"referent_kind:11","concept":"concept:21","entity":null,"features":[]}],"#, + r#""predications":[{"id":"n3","concept":"concept:27","frame":"frame:6","roles":[{"role":"role:3","filler":"n1"},{"role":"role:6","filler":"n2"}],"features":[]}],"#, + r#""relations":[],"extensions":[],"#, + r#""stance":{"features":[{"feature":"feature:18","value":{"Grade":800}},{"feature":"feature:16","value":{"Grade":300}}]}}"# + ) + ); + assert_eq!(head, expected); + assert!(listing.starts_with("concept:1: endanger\n")); + assert!(listing.contains("\nfeature:18: ")); + // The brief says what the spec says it must. + for needed in [ + "en-US", + "all of its meaning and nothing it does not carry", + "felt affect and restraint", + "shapes how the utterance is said, not what it says", + "forms on the entities line", + r#"{"text": "..."}"#, + ] { + assert!(LOWER.contains(needed), "{needed}"); + } + // The one call it makes is its item; the key is in no text. + assert_eq!(prompt.item_keys(), [item.as_str()]); + assert!(!prompt.text().contains(item.as_str())); + } + + #[test] + fn an_entity_referent_prints_its_en_us_forms_citation_first() { + 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 item = CorpusItemId::parse("item:nibu:1").unwrap(); + let held = Held(vec![nibu(&[ + ("sa-Latn", &["Nibuwa"]), + ("en-US", &["Nibu", "Nibu's"]), + ])]); + let prompt = Prompt::lower(&view, &item, &cast.inventory().unwrap(), &held).unwrap(); + assert!( + prompt + .text() + .contains("\n\nentities: {\"entity:nibu\":[\"Nibu\",\"Nibu's\"]}\n\n"), + "{}", + prompt.text() + ); + assert!(!prompt.text().contains("Nibuwa")); + } + + #[test] + fn lower_prompt_refuses_an_entity_without_an_english_form() { + 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 id = EntityId::parse("entity:nibu").unwrap(); + for (held, want) in [ + ( + Held(vec![nibu(&[("sa-Latn", &["Nibuwa"])])]), + LowerError::NoForm { entity: id.clone() }, + ), + ( + Held(vec![nibu(&[("en-US", &[])])]), + LowerError::NoForm { entity: id.clone() }, + ), + (Held(vec![]), LowerError::UnknownEntity { entity: id }), + ] { + assert_eq!(Prompt::lower(&view, &item, &inv, &held).err(), Some(want)); + } + } +} diff --git a/crates/weksa/tests/golf_loop.rs b/crates/weksa/tests/golf_loop.rs index b40a260..02adaab 100644 --- a/crates/weksa/tests/golf_loop.rs +++ b/crates/weksa/tests/golf_loop.rs @@ -669,11 +669,11 @@ fn answer_files_write_the_prompt_and_read_the_answer() { use std::collections::HashMap; use common::Bag; -use weksa::context::{ENCODE_INSTRUCTIONS, MINT_INSTRUCTIONS, Prompt}; +use weksa::context::{ENCODE_INSTRUCTIONS, LOWER_INSTRUCTIONS, MINT_INSTRUCTIONS, Prompt}; use weksa::daodejing::packets; use weksa::model::{AnswerFiles, ModelPort, PortError}; use weksa::run::{Outcome, RoundOutcome, RoundRecord, Stage, round}; -use weksa::{Embedder, PacketDraft, PacketStore, Scorer}; +use weksa::{CorpusStore, Embedder, InventoryStore, PacketDraft, PacketStore, Scorer}; /// Answers by the item the prompt holds; panics on an item it was not given, /// and records every prompt it was shown. A prompt that starts with @@ -683,6 +683,11 @@ use weksa::{Embedder, PacketDraft, PacketStore, Scorer}; struct FakeModel { name: &'static str, answers: HashMap, + /// The lowering answered for an item: `{"text": lowered[item]}`; a panic on + /// an item with none. + lowered: HashMap, + /// When set, the answer to every lowering prompt instead of `lowered`. + raw_lowering: Option>, candidate: Option<(u32, HashMap)>, edit: Option>, seen: Vec<(String, String)>, @@ -700,6 +705,16 @@ impl ModelPort for FakeModel { return self.edit.clone().expect("the model was asked for an edit"); } let key = prompt.item_keys()[0].clone(); + if prompt.text().starts_with(LOWER_INSTRUCTIONS) { + if let Some(raw) = &self.raw_lowering { + return raw.clone(); + } + let text = self + .lowered + .get(&key) + .unwrap_or_else(|| panic!("the model was asked to lower {key}")); + return Ok(serde_json::json!({ "text": text }).to_string()); + } let answers = match &self.candidate { Some((rev, candidate)) if prompt @@ -732,11 +747,30 @@ fn keyed(answers: &[(usize, String)]) -> HashMap { .collect() } +/// The five English texts the deleted template table gave the Dao packets +/// (`tests/daodejing.rs`): the lowering the fake model answers, so the score +/// pins hold. +fn table_english() -> HashMap { + [ + "The sage empties the minds. The sage fills the bellies. The sage weakens the wills. The sage strengthens the bones.", + "The heaven is long-enduring. The earth continues long.", + "The highest excellence is like the water.", + "The words that are strictly true seem paradoxical.", + "The space between heaven and earth may be compared to the bellows.", + ] + .iter() + .enumerate() + .map(|(i, text)| (format!("item:daodejing:{}", i + 1), (*text).to_string())) + .collect() +} + /// A fake that answers the Dao packets for the Train and Validation items. fn fake(answers: &[(usize, String)]) -> FakeModel { FakeModel { name: "fake-model", answers: keyed(answers), + lowered: table_english(), + raw_lowering: None, candidate: None, edit: None, seen: vec![], @@ -823,10 +857,14 @@ fn round_over_the_dao_stores_scores_train_and_validation() { assert_eq!( items, [ + ["item:daodejing:1"], + ["item:daodejing:2"], + ["item:daodejing:3"], ["item:daodejing:1"], ["item:daodejing:2"], ["item:daodejing:3"] - ] + ], + "the encode calls, then the lowering calls" ); for (call, (digest, _)) in r.calls().iter().zip(&model.seen) { assert_eq!(&call.prompt, digest); @@ -943,6 +981,28 @@ fn prompts(run: &RunStores) -> Vec<(String, Prompt)> { .collect() } +/// The key and prompt of every lowering a round over `dao`'s run asks for when +/// every item encodes to its Dao packet and is admitted, as a round builds them. +fn lowering_prompts(dao: &Dao, inventory: &InventoryStore) -> Vec<(String, Prompt)> { + let corpus = CorpusStore::open(&dao.corpus).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut store = PacketStore::open(&dir.path().join("p.cc")).unwrap(); + let inv = inventory.inventory().unwrap(); + (1..=3) + .map(|n| { + let mut packet = packets().remove(n - 1); + // The encoder step stamps the inventory's latest revision. + packet.inventory_rev = inv.latest().unwrap().n; + let key = packet.provenance.corpus_item.clone(); + let view = store + .admit(packet, inventory, &corpus, dao.run.entities()) + .unwrap(); + let prompt = Prompt::lower(&view, &key, &inv, dao.run.entities()).unwrap(); + (key.to_string(), prompt) + }) + .collect() +} + #[test] fn unanswered_round_writes_nothing() { let mut dao = dao(); @@ -991,12 +1051,53 @@ fn unanswered_round_writes_nothing() { assert!(!round1.join("packets.cc").exists()); assert_eq!(std::fs::read(dao.root.join("run.cc")).unwrap(), run_cc); - // The last answer completes round 1; the next round is round 2 and asks again. + // The last encode answer moves the round to its second phase: the lowering + // prompts are asked, and still nothing is written. let (key, p) = &expected[2]; std::fs::write(round1.join(format!("{}.answer", p.digest())), &answers[key]).unwrap(); + let lowering = lowering_prompts(&dao, dao.run.inventory()); + let mut files = AnswerFiles::new(dao.run.next_round_dir().unwrap(), "fake-model".into()); + let RoundOutcome::Unanswered { prompts: digests } = + round(&mut dao.run, &mut files, &Scorer::new(Bag)).unwrap() + else { + panic!("answered lowering from nothing") + }; + let want_lower: Vec = lowering.iter().map(|(_, p)| p.digest()).collect(); + assert_eq!(digests, want_lower); + assert!(dao.run.rounds().unwrap().is_empty()); + assert!(!round1.join("packets.cc").exists()); + assert_eq!(std::fs::read(dao.root.join("run.cc")).unwrap(), run_cc); + for (_, p) in &lowering { + assert_eq!( + std::fs::read_to_string(round1.join(format!("{}.prompt", p.digest()))).unwrap(), + p.text() + ); + } + + // Two of three lowerings is still nothing written; the third completes round 1. + let table = table_english(); + let lowered = |key: &str| serde_json::json!({ "text": table[key] }).to_string(); + for (key, p) in &lowering[..2] { + std::fs::write(round1.join(format!("{}.answer", p.digest())), lowered(key)).unwrap(); + } + let mut files = AnswerFiles::new(dao.run.next_round_dir().unwrap(), "fake-model".into()); + let RoundOutcome::Unanswered { prompts: digests } = + round(&mut dao.run, &mut files, &Scorer::new(Bag)).unwrap() + else { + panic!("answered from two of three lowerings") + }; + assert_eq!(digests, [want_lower[2].clone()]); + assert!(dao.run.rounds().unwrap().is_empty()); + assert!(!round1.join("packets.cc").exists()); + assert_eq!(std::fs::read(dao.root.join("run.cc")).unwrap(), run_cc); + let (key, p) = &lowering[2]; + std::fs::write(round1.join(format!("{}.answer", p.digest())), lowered(key)).unwrap(); let mut files = AnswerFiles::new(dao.run.next_round_dir().unwrap(), "fake-model".into()); let r = recorded(round(&mut dao.run, &mut files, &Scorer::new(Bag)).unwrap()); - assert_eq!(r.n(), 1); + assert_eq!((r.n(), r.calls().len()), (1, 6)); + assert_eq!(two_dp(r.train_mean().chrf.unwrap()), 44.18); + + // Round 2 asks again, encode first. let mut files = AnswerFiles::new(dao.run.next_round_dir().unwrap(), "fake-model".into()); assert!(dao.root.join("round-2").parent().is_some()); let RoundOutcome::Unanswered { prompts: again } = @@ -1011,11 +1112,12 @@ fn unanswered_round_writes_nothing() { ); assert_eq!(dao.run.rounds().unwrap().len(), 1); - // Answered, round 2 is recorded as round 2, after round 1. - for (key, p) in &expected { + // Answered in both phases, round 2 is recorded as round 2, after round 1. + for (key, p) in expected.iter().chain(&lowering) { + let answer = answers.get(key).cloned().unwrap_or_else(|| lowered(key)); std::fs::write( dao.root.join(format!("round-2/{}.answer", p.digest())), - &answers[key], + answer, ) .unwrap(); } @@ -1105,7 +1207,8 @@ fn refusal_stages_are_counted() { }; let junk = "not json at all".to_string(); - // Train refused at each stage; Validation refused at the other two. + // Train refused at Parse; Validation refused at Admit, and a relation + // lowers (the model renders relations; the deleted table did not). let r = run([ junk.clone(), edited(2, unknown_concept), @@ -1116,14 +1219,15 @@ fn refusal_stages_are_counted() { // A refused item is in the mean as 0: n counts every item, scored the real ones. let m = r.train_mean(); assert_eq!((m.count, m.chrf, m.meaning), (1, Some(0.0), Some(0.0))); - assert_eq!((r.train_scored(), r.validation_scored()), (0, 0)); + assert_eq!((r.train_scored(), r.validation_scored()), (0, 1)); assert_eq!(r.validation_refused().parse, 0); assert_eq!( (r.validation_refused().admit, r.validation_refused().lower), - (1, 1) + (1, 0) ); let m = r.validation_mean(); - assert_eq!((m.count, m.chrf, m.meaning), (2, Some(0.0), Some(0.0))); + assert_eq!(m.count, 2); + assert!(m.chrf.unwrap() > 0.0, "the relation item lowered and scored"); // Validation items 2 and 3 score a and b when both lower (the Dao bed: // 82.12 and 74.99). Refusing one halves the mean; it cannot raise it. @@ -1157,12 +1261,7 @@ fn refusal_stages_are_counted() { "(74.99 + 0) / 2 = {b_half}" ); - let a_only = run([edited(1, with_relation), good_answers()[1].1.clone(), junk]); - assert_eq!(a_only.train()[0].outcome, Outcome::Refused(Stage::Lower)); - assert!( - a_only.train()[0].packet.is_some(), - "a packet that lowers badly was admitted" - ); + let a_only = run([good_answers()[0].1.clone(), good_answers()[1].1.clone(), junk]); assert_eq!(a_only.validation_refused().parse, 1); let a_half = a_only.validation_mean().chrf.unwrap(); assert!( @@ -1200,8 +1299,18 @@ fn non_utf8_encode_answer_is_a_parse_refusal() { }; std::fs::write(round1.join(format!("{}.answer", p.digest())), bytes).unwrap(); } + // Item 3 never reaches the second phase. + let table = table_english(); + for (key, p) in lowering_prompts(&dao, dao.run.inventory()) + .iter() + .filter(|(key, _)| key != "item:daodejing:3") + { + let answer = serde_json::json!({ "text": table[key] }).to_string(); + std::fs::write(round1.join(format!("{}.answer", p.digest())), answer).unwrap(); + } let mut files = AnswerFiles::new(round1.clone(), "fake-model".into()); let r = recorded(round(&mut dao.run, &mut files, &Scorer::new(Bag)).unwrap()); + assert_eq!(r.calls().len(), 5); // Item 3 is Validation: its refusal is a Parse refusal and the round stands. let v = r.validation_refused(); assert_eq!((v.parse, v.admit, v.lower), (1, 0, 0)); @@ -1459,9 +1568,9 @@ fn errors_echo_no_input() { format!("{:?}", RunError::Drift(DriftOf::Model)), "drift in Model" ); - // The Prompts text names the two files its digest covers, and only them. + // The Prompts text names the three files its digest covers. let prompts = RunError::Drift(DriftOf::Prompts).to_string(); - assert!(prompts.contains("context.rs") && prompts.contains("split.rs")); + assert!(["context.rs", "split.rs", "lower.rs"].iter().all(|f| prompts.contains(f))); assert_eq!(format!("{:?}", RunError::Drift(DriftOf::Prompts)), prompts); } @@ -1471,11 +1580,18 @@ fn prompts_come_only_from_context() { let mut model = fake(&good_answers()); let r = recorded(round(&mut dao.run, &mut model, &Scorer::new(Bag)).unwrap()); assert_eq!(model.seen.len(), r.calls().len()); + assert_eq!(model.seen.len(), 6); for ((digest, text), call) in model.seen.iter().zip(r.calls()) { - assert!(text.starts_with(ENCODE_INSTRUCTIONS)); assert_eq!(call.items.len(), 1); - assert_eq!(text.matches("[item:").count(), 1); - assert!(text.contains(&format!("[{}]", call.items[0]))); + if text.starts_with(ENCODE_INSTRUCTIONS) { + assert_eq!(text.matches("[item:").count(), 1); + assert!(text.contains(&format!("[{}]", call.items[0]))); + } else { + // A lowering prompt holds no item block and prints no item key. + assert!(text.starts_with(LOWER_INSTRUCTIONS)); + assert_eq!(text.matches("[item:").count(), 0); + assert!(!text.contains("item:daodejing")); + } assert_eq!(&call.prompt, digest); assert_eq!(*digest, format!("{:016x}", fnv1a64(text.as_bytes()))); } @@ -1948,6 +2064,28 @@ fn unanswered_cycle_writes_nothing() { assert_eq!(dao.run.rounds().unwrap().len(), 2); assert!(dao.run.decisions().unwrap().is_empty()); + // With the encodes answered, the candidate round asks its lowerings, listing + // the candidate inventory; still nothing is written. + let candidate = InventoryStore::open(&dir.join("inventory.cc")).unwrap(); + let lowering = lowering_prompts(&dao, &candidate); + let CycleOutcome::Unanswered { prompts: lower } = ask(&mut dao) else { + panic!("answered the candidate lowerings from nothing") + }; + assert_eq!( + lower, + lowering.iter().map(|(_, p)| p.digest()).collect::>() + ); + assert!(lowering[0].1.text().contains("\n\ninventory rev:4\n")); + assert!(!dir.join("packets.cc").exists()); + assert_eq!(std::fs::read(dao.root.join("run.cc")).unwrap(), run_cc); + assert_eq!(dao.run.rounds().unwrap().len(), 2); + assert!(dao.run.decisions().unwrap().is_empty()); + let table = table_english(); + for (key, p) in &lowering { + let answer = serde_json::json!({ "text": table[key] }).to_string(); + std::fs::write(dir.join(format!("{}.answer", p.digest())), answer).unwrap(); + } + // Answered, the cycle is decided; the next one uses cycle-2. let d = decided(ask(&mut dao)); assert_eq!((d.k(), d.verdict()), (1, Verdict::Accepted)); @@ -1966,6 +2104,21 @@ fn unanswered_cycle_writes_nothing() { #[test] fn the_editor_sees_train_only() { + run_the_editor_canary(CANARY_LOWERED); +} + +/// What the lowerer says for the canary Validation item. +const CANARY_LOWERED: &str = "CANARYLOWERED"; + +#[test] +fn validation_lowerings_never_reach_the_editor() { + // The same run under another lowering for the canary: the editor's prompts + // never print either, and nothing recorded holds either. + run_the_editor_canary("CANARYLOWERED"); + run_the_editor_canary("the zebra ate the quartz"); +} + +fn run_the_editor_canary(canary_lowered: &str) { let canary_key = "item:canary:1"; let canary_text = "CANARYVALIDATIONTEXT"; let mut dao = dao_over(|corpus, entities| { @@ -1980,6 +2133,7 @@ fn the_editor_sees_train_only() { }); let with_canary = |mut m: FakeModel| { m.answers.insert(canary_key.into(), json(&draft_of(2))); + m.lowered.insert(canary_key.into(), canary_lowered.into()); if let Some((_, c)) = m.candidate.as_mut() { c.insert(canary_key.into(), json(&draft_of(2))); } @@ -2006,7 +2160,12 @@ fn the_editor_sees_train_only() { assert_eq!(d2.base_round(), 1); let corpus = weksa::CorpusStore::open(&dao.corpus).unwrap(); - let mut held_out: Vec = vec![canary_key.into(), canary_text.into(), "canary".into()]; + let mut held_out: Vec = vec![ + canary_key.into(), + canary_text.into(), + "canary".into(), + canary_lowered.into(), + ]; for n in 2..=5 { let item = corpus.get(&format!("item:daodejing:{n}")).unwrap().unwrap(); held_out.extend([item.id, item.text]); @@ -2052,8 +2211,22 @@ fn the_editor_sees_train_only() { ); } } + // Round records list the keys of the items they called, never lowered text. + for text in [ + format!("{:?}", dao.run.rounds().unwrap()), + format!("{:?}", dao.run.decisions().unwrap()), + ] { + for needle in [canary_lowered, canary_text] { + assert!(!text.to_lowercase().contains(&needle.to_lowercase())); + } + } let run_cc = std::fs::read(dao.root.join("run.cc")).unwrap(); - for needle in [canary_text, "item:daodejing:4", "item:daodejing:5"] { + for needle in [ + canary_text, + canary_lowered, + "item:daodejing:4", + "item:daodejing:5", + ] { assert!( !run_cc.windows(needle.len()).any(|w| w == needle.as_bytes()), "{needle}" @@ -2352,6 +2525,18 @@ fn source_tripwires() { .collect(); let count = |needle: &str| code.iter().filter(|l| l.contains(needle)).count(); assert!(!run.contains("pub fn new")); + // The round does not call the template table: the model lowers. + assert_eq!(count("lower(&"), 0, "run.rs calls the table"); + assert_eq!(count("crate::lower::lower"), 0, "run.rs imports the table"); + assert_eq!(count("Prompt::lower("), 1, "one lowering call site"); + // Prompt::lower takes the view, the item's key, the inventory and the entity + // port: no corpus item, train set, offer or store. + let start = context.find("pub fn lower(").unwrap(); + let signature = &context[start..start + context[start..].find(") -> ").unwrap()]; + for banned in ["CorpusItem,", "CorpusItem)", "TrainItems", "EncoderOffer", "Store"] { + assert!(!signature.contains(banned), "Prompt::lower takes {banned}"); + } + assert!(signature.contains("&LoweringInput") && signature.contains("&CorpusItemId")); assert_eq!(count("admit_revision("), 1, "one door call"); assert_eq!( count("std::fs::copy"), @@ -2384,3 +2569,162 @@ fn source_tripwires() { ); } } + +// ---- the lowerer ---- + +/// Lowercase letters and digits only: what survives any re-spacing or re-casing. +fn squashed(text: &str) -> String { + text.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +#[test] +fn the_lowerer_sees_no_item_text() { + let canary_key = "item:canary:1"; + let mut dao = dao_over(|corpus, entities| { + let entities = weksa::EntityStore::open(entities).unwrap(); + let mut store = weksa::CorpusStore::open(corpus).unwrap(); + let mut item = store.get("item:daodejing:2").unwrap().unwrap(); + item.id = canary_key.into(); + item.text = "CANARYVALIDATIONTEXT of the zebra".into(); + item.names = vec![]; + item.prior_turns = vec![]; + store.add(item, &entities).unwrap(); + }); + let mut model = fake(&good_answers()); + model.answers.insert(canary_key.into(), json(&draft_of(2))); + model.lowered.insert(canary_key.into(), "CANARYLOWERED".into()); + let r = recorded(round(&mut dao.run, &mut model, &Scorer::new(Bag)).unwrap()); + + let corpus = weksa::CorpusStore::open(&dao.corpus).unwrap(); + let items = corpus.all().unwrap(); + assert!(items.len() >= 6, "the canary and the five Legge items"); + let packet_keys: Vec = PacketStore::open(&dao.root.join("round-1/packets.cc")) + .unwrap() + .all() + .unwrap() + .iter() + .map(|p| p.id.to_string()) + .collect(); + assert_eq!(packet_keys.len(), 4); + + let lowering: Vec<&(String, String)> = model + .seen + .iter() + .filter(|(_, t)| t.starts_with(LOWER_INSTRUCTIONS)) + .collect(); + assert_eq!(lowering.len(), 4, "one lowering per admitted packet"); + for (digest, text) in &lowering { + let flat = squashed(text); + for item in &items { + // Any split: Train, Validation and Test text alike. + assert!(!text.contains(&item.text), "{}", item.id); + assert!(!flat.contains(&squashed(&item.text)), "{}", item.id); + for needle in [item.id.as_str(), item.group.as_str(), item.speaker.as_str()] { + assert!(!text.contains(needle), "{needle}"); + } + } + for key in &packet_keys { + assert!(!text.contains(key.as_str()), "{key}"); + } + assert!(!text.to_lowercase().contains("canary")); + assert!(!text.contains("confidence")); + // Its one call is its item. + let call = r.calls().iter().find(|c| &c.prompt == digest).unwrap(); + assert_eq!(call.items.len(), 1); + assert!(call.items[0].starts_with("item:")); + } + // The encode and lowering calls name the same four items. + let keys = |lowering: bool| { + let mut keys: Vec<&str> = r + .calls() + .iter() + .filter(|c| { + lowering + == model.seen.iter().any(|(d, t)| { + d == &c.prompt && t.starts_with(LOWER_INSTRUCTIONS) + }) + }) + .map(|c| c.items[0].as_str()) + .collect(); + keys.sort_unstable(); + keys + }; + assert_eq!(keys(true), keys(false)); +} + +#[test] +fn lowering_refusals_count_at_lower() { + let run = |raw: Result| { + let mut dao = dao(); + let mut model = fake(&good_answers()); + model.raw_lowering = Some(raw); + recorded(round(&mut dao.run, &mut model, &Scorer::new(Bag)).unwrap()) + }; + for (what, raw) in [ + ("non-JSON", Ok("The sage empties the minds.".to_string())), + ("unknown key", Ok(r#"{"text":"a","trace":"b"}"#.to_string())), + ("missing text", Ok("{}".to_string())), + ("empty text", Ok(r#"{"text":""}"#.to_string())), + ("blank text", Ok("{\"text\":\" \\n \"}".to_string())), + ("non-UTF-8", Err(PortError::Io(std::io::ErrorKind::InvalidData))), + ] { + let r = run(raw); + assert_eq!(r.train()[0].outcome, Outcome::Refused(Stage::Lower), "{what}"); + assert!( + r.train()[0].packet.is_some(), + "{what}: the admitted packet is kept on the Train row" + ); + let v = r.validation_refused(); + assert_eq!((v.parse, v.admit, v.lower), (0, 0, 2), "{what}"); + assert_eq!((r.train_scored(), r.validation_scored()), (0, 0), "{what}"); + let m = r.validation_mean(); + assert_eq!((m.count, m.chrf), (2, Some(0.0)), "{what}"); + } +} + +#[test] +fn an_added_entry_can_be_lowered() { + // The edit adds a concept; the candidate encode answer cites it. The + // candidate's lowering prompt lists it and the item scores: the gain path a + // table with no row for it blocked. + let mut dao = dao(); + two_rounds(&mut dao); + let mut cited = draft_of(1); + cited.referents[0].concept = "concept:28".parse().unwrap(); + let mut model = cycle_fake(ONE_CONCEPT); + model + .candidate + .as_mut() + .unwrap() + .1 + .insert("item:daodejing:1".into(), json(&cited)); + run_cycle(&mut dao, &mut model); + let candidate_lowerings: Vec<&str> = model + .seen + .iter() + .map(|(_, t)| t.as_str()) + .filter(|t| t.starts_with(LOWER_INSTRUCTIONS) && t.contains("\n\ninventory rev:4\n")) + .collect(); + assert_eq!(candidate_lowerings.len(), 3); + for text in &candidate_lowerings { + assert!(text.contains("\nconcept:28: a general meaning the Dao names")); + } + assert_eq!( + candidate_lowerings + .iter() + .filter(|t| t.contains(r#""concept":"concept:28""#)) + .count(), + 1, + "exactly the item that cites the new concept" + ); + let rounds = dao.run.rounds().unwrap(); + let candidate = rounds.last().unwrap(); + assert_eq!(candidate.inventory_rev(), 4); + assert!(matches!( + candidate.train()[0].outcome, + Outcome::Scored { .. } + )); +} diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 590bb7a..2720c60 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -343,7 +343,8 @@ measurable baseline from a packet: `lower` gives text, `score` gives numbers. | Decision | Owner | Inputs | Outputs | Not the owner | |---|---|---|---|---| | Whether a packet exists | `validate` / `PacketStore::admit` (unchanged) | packet, inventory, corpus, entities | admitted packet or typed refusal | the lowerer | -| Wording of a packet in English | `crates/weksa/src/lower.rs`: `lower(&LoweringInput, &Inventory) -> Result` | the validated lowering view and the inventory at its revision | text, or a typed refusal naming the missing template | the corpus item, the scorer, any model | +| Wording of a scored item in English (golf loop, `golf-loop-model-lowerer`) | the ModelPort's answer to `Prompt::lower` (`context.rs`), read as `LoweringDraft` (`encode.rs`) and held in `run::score_round`'s locals | the admitted view, the inventory at its revision, en-US entity forms | `{"text"}`, scored once, never stored | the corpus item, the scorer, the editor | +| Wording of a packet in English (`weksa lower`, proof fixtures) | `crates/weksa/src/lower.rs`: `lower(&LoweringInput, &Inventory) -> Result` | the validated lowering view and the inventory at its revision | text, or a typed refusal naming the missing template | the corpus item, the scorer, the golf loop | | English template table (concept words, frame templates, speech-act openers) | the same module, as rows `(inventory id, gloss it speaks for, surface)` | none; it is data in code | rows the lowerer looks up | the inventory (it owns the ids and glosses, not the English) | | The inventory's concepts and frames | an inventory revision; here `inventory::proof_revision()` (rev 2), hand-seeded as `seed()` is | none | the 6 concepts and 3 frames `nibu-proof-001` and the consent-gated request packet name | the lowerer | | What a score counts | `Scorer::score` (unchanged) | output text, reference text | `ItemScore` | `weksa score`, which only reads text and calls it | @@ -405,9 +406,10 @@ One line per cut; specs exist for the cuts about to run (2d and 4, siblings on ` 5. `language-ontology`: `weksa.community` from `data/cultural-ontology/` (owns follow-up `edda-referent-yaml-parse`), `weksa.ontology_entry` for en-US and ja-JP from `data/target-language-ontology/` (YAML deleted), speaker communities, deterministic `select`, the lowering view (packet without trace, speaker, scene, selected entries). Waits on 4. 6. `pd-importer`: public-domain play and fiction dialogue into the corpus with speaker, scene group, edition provenance, and Speaker and Scene annotations by the ruled annotator. Waits on 4, follow-up `gutenberg-terms` and question `pd-speaker-annotation`. 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`, `LOWERER_SOURCE_DIGEST` of `lower.rs`, 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. +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. -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, lowerer prompts, Score documents, `weksa score` in the loop, the detached unit on Yggdrasil. Waits on 3, 5, 6 and follow-up `claude-on-yggdrasil`. +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. +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. 10. `conlang-lexicon`: the first conlang's lexicon placed at concept embeddings, its morphology data, its ontology entries and the gloss reader. Waits on 3, 5 and question `first-conlang`. diff --git a/schemas/cultnet/weksa.loop_round.schema.json b/schemas/cultnet/weksa.loop_round.schema.json index ec4111a..c2dee90 100644 --- a/schemas/cultnet/weksa.loop_round.schema.json +++ b/schemas/cultnet/weksa.loop_round.schema.json @@ -5,6 +5,7 @@ "type": "object", "properties": { "calls": { + "description": "The encode calls, then the lowering calls, each with its prompt digest\nand one item key.", "type": "array", "items": { "$ref": "#/$defs/Call" @@ -218,11 +219,19 @@ }, "Stage": { "description": "Where a round refused: the first stage that did.", - "type": "string", - "enum": [ - "Parse", - "Admit", - "Lower" + "oneOf": [ + { + "type": "string", + "enum": [ + "Parse", + "Admit" + ] + }, + { + "description": "The lowering prompt could not be built, or its answer was not UTF-8, not\na `LoweringDraft`, or empty text.", + "type": "string", + "const": "Lower" + } ] }, "TrainRow": { From b569028f482f31bee2efbf8aa40163d28c52f6ca Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 09:32:28 +0000 Subject: [PATCH 3/3] Soul s1 fixes: pin the lowerer reach, bound lowering text, pin the build refusal and the I/O discard RunRecord key 12 LowererReach set by a required --lowerer-reach flag and shown in round and cycle lines. PROMPT_SOURCE_DIGEST covers packet.rs. Lowering text is refused when blank, over 4096 bytes or holding a control character. Unanswered lowering digests are listed once. Tests for the Prompt::lower build refusal and the packets.cc discard after a port error. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/context.rs | 19 +- crates/weksa/src/encode.rs | 20 +- crates/weksa/src/main.rs | 14 +- crates/weksa/src/run.rs | 75 ++++++- crates/weksa/tests/cli.rs | 42 +++- crates/weksa/tests/common/mod.rs | 2 +- crates/weksa/tests/golf_loop.rs | 199 +++++++++++++++++-- docs/targets/interlingua-lowering-map.md | 2 +- schemas/cultnet/weksa.loop_round.schema.json | 2 +- schemas/cultnet/weksa.loop_run.schema.json | 26 ++- 10 files changed, 355 insertions(+), 46 deletions(-) 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" + } + ] + } + } }