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 <noreply@anthropic.com>
This commit is contained in:
Weksa 2026-11-10 09:32:28 +00:00
commit b569028f48
10 changed files with 355 additions and 46 deletions

View file

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

View file

@ -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<Self, DraftError> {
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:<hex16>`, 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:?}")] {

View file

@ -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 } => {

View file

@ -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 `<root>/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<Self, String> {
[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, RunError> {
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<Self, RunError> {
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, RunError> {
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<M: ModelPort, E: Embedder>(
});
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<M: ModelPort, E: Embedder>(
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();

View file

@ -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
);
}

View file

@ -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()
}

View file

@ -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::<LowererReach>().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);
}

View file

@ -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 `<root>/run.cc` and refuses drift (corpus, entities, `PROMPT_SOURCE_DIGEST` of `context.rs`, a second pin on `lower.rs` retired in 7d, model, embedder); `round` encodes every live Train and Validation item through a `ModelPort` (`model.rs`; the one adapter `AnswerFiles` writes `<digest>.prompt` and reads `<digest>.answer`, so the model is the Claude session), reads each answer into a `PacketDraft` (`encode.rs`, the one place a model's JSON is read), admits through `PacketStore::admit` into `<root>/round-<n>/packets.cc`, lowers, scores once, and keeps a `RoundRecord` (`weksa.loop_round`, no text, no Test; Train rows, Validation only as means and refusal counts). `weksa loop open|round`. Not here: the spec-edit step, accept rule and composite, noise floor, Score and lowering documents, a subprocess adapter, the checkpoint door, a model lowerer (follow-ups `golf-loop-spec-edit`, `golf-loop-accept-rule`, `golf-loop-score-documents`, `golf-loop-subprocess-adapter`, `golf-loop-checkpoint-door-bound`, `golf-loop-model-lowerer`). `packet-store-inventory-binding` is met for loop packets only (`RunStores` admits with the run's inventory); `validate`, `packet proof|dao` and `lower` still take caller stores. Waits on 7a, `checkpoint-gate`, 4b.
7c. `golf-loop-edit-accept` (spec r1; branch `weksa-lowering/golf-loop-edit-accept`): one edit-accept cycle. The run works on its own copy of the inventory (`create` copies the caller's file to `<root>/inventory.cc`, refusing a root that already holds a file of that name, and pins its digest as `RunRecord` key 11; `RunRecord.inventory` is provenance; a `run.cc` from before has no key 11 and does not open); the head is derived from the latest Accepted `Decision`. `weksa loop cycle` asks the editor for one inventory edit from `Prompt::mint(train, inventory, rev, feedback)` (Train blocks, the inventory listing and an `EditFeedback` line: whether the last edit was kept and the head's Validation composite rounded to `LADDER_STEP`, the Ladder release of Blum and Hardt 2015), reads it as an `EditDraft` (`encode.rs`; ids and revisions are the store's, `into_revision`), admits it through `InventoryStore::admit_revision` into `<root>/cycle-<k>/inventory.cc`, runs a candidate round under it, and `decide`s on Validation against the head's establishing round past a floor 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.

View file

@ -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"
}

View file

@ -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"
}
]
}
}
}