diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index 8914724..a4f2749 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -6,6 +6,8 @@ //! [`ENCODE_INSTRUCTIONS`], consts in this module, and nothing else: `mint` and //! `encode` take none, so no run-time text can become instructions. +use std::marker::PhantomData; + use crate::corpus::CorpusItem; use crate::packet::EntityId; use crate::split::{Checkpoint, Split, SplitScheme}; @@ -98,14 +100,42 @@ pub enum OfferRefusal { /// One item the encoder may be shown, with the entities a packet for it may /// cite. Built per item, so a call sees one item and nothing of its group. -pub struct EncoderOffer { +/// +/// An offer made under a [`Checkpoint`] borrows it, so it is dead before +/// `TestReadings::at_checkpoint` can spend the checkpoint, and a Test offer is +/// never encoded after (or beside) the reading. A round offer borrows nothing. +/// +/// ``` +/// use weksa::context::{EncoderOffer, TrainItems}; +/// use weksa::split::Checkpoint; +/// use weksa::{CorpusItem, score::TestReadings}; +/// fn f(mut c: Checkpoint, i: &CorpusItem, t: &TrainItems) { +/// if let Ok(o) = EncoderOffer::for_checkpoint(i, &mut c) { +/// let _ = weksa::context::Prompt::encode(&o, t); +/// } +/// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]); +/// } +/// ``` +/// +/// ```compile_fail +/// use weksa::context::{EncoderOffer, TrainItems}; +/// use weksa::split::Checkpoint; +/// use weksa::{CorpusItem, score::TestReadings}; +/// fn f(mut c: Checkpoint, i: &CorpusItem, t: &TrainItems) { +/// let o = EncoderOffer::for_checkpoint(i, &mut c).unwrap(); +/// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]); +/// let _ = weksa::context::Prompt::encode(&o, t); +/// } +/// ``` +pub struct EncoderOffer<'cp> { item: CorpusItem, entities: Vec, + _checkpoint: PhantomData<&'cp mut Checkpoint>, } -impl EncoderOffer { +impl<'cp> EncoderOffer<'cp> { /// A round offers Train and Validation items. - pub fn for_round(item: &CorpusItem) -> Result { + pub fn for_round(item: &CorpusItem) -> Result, OfferRefusal> { match Self::gate(item)? { Split::Test => Err(OfferRefusal::TestBeforeCheckpoint), _ => Ok(Self::of(item)), @@ -117,7 +147,7 @@ impl EncoderOffer { /// item records no key. pub fn for_checkpoint( item: &CorpusItem, - checkpoint: &mut Checkpoint, + checkpoint: &'cp mut Checkpoint, ) -> Result { Self::gate(item)?; if !checkpoint.first_offer(&item.id) { @@ -133,13 +163,14 @@ impl EncoderOffer { SplitScheme::V1.live(item).ok_or(OfferRefusal::NoSplit) } - fn of(item: &CorpusItem) -> Self { + fn of<'a>(item: &CorpusItem) -> EncoderOffer<'a> { let mut entities: Vec = item.cited_entities().cloned().collect(); entities.sort(); entities.dedup(); - Self { + EncoderOffer { item: item.clone(), entities, + _checkpoint: PhantomData, } } diff --git a/crates/weksa/src/score/mod.rs b/crates/weksa/src/score/mod.rs index c531e89..2b55f3b 100644 --- a/crates/weksa/src/score/mod.rs +++ b/crates/weksa/src/score/mod.rs @@ -158,7 +158,7 @@ pub fn aggregate(scored: &[(&CorpusItem, ItemScore)]) -> Aggregate { /// The Test split's readings, scalars only: no item key, no minted string, no /// pair. The checkpoint decision (convergence-test) needs numbers; a key beside /// a minted string is the handle an edit step would act on. The only producer -/// is `at_checkpoint`, which consumes a `Checkpoint`, which only the lib can make. +/// is `at_checkpoint`, which consumes a `Checkpoint`; only the lib can make one. /// /// ``` /// use weksa::score::TestReadings; diff --git a/crates/weksa/src/split.rs b/crates/weksa/src/split.rs index 5d3a5ee..e50dc7b 100644 --- a/crates/weksa/src/split.rs +++ b/crates/weksa/src/split.rs @@ -1,7 +1,11 @@ //! The train/validation/test split is derived from an item's group and nothing //! else. No stored assignment exists, so no writer can reassign one. An item is -//! read in its live split; Test is read only under a Checkpoint, and only the -//! lib can make one (convergence-freeze adds the door). +//! read in its live split. The round path (round records, prompts, `aggregate`, +//! `leak_diagnostic`) carries no Test reading; a typed `TestReadings` and a Test +//! `EncoderOffer` need a Checkpoint, and only the lib can make one +//! (convergence-freeze adds the door). Operator tools (`weksa corpus dump`, +//! `weksa score`, `CorpusStore`) read corpus text of any split; that is not +//! gated here. use std::collections::BTreeSet; @@ -66,14 +70,18 @@ impl SplitScheme { } } -/// Permission to read Test once. Only the weksa lib can make one: there is no -/// public constructor, so nothing outside it (the binary, a driver crate, a -/// doctest, an integration test) can read Test. The one door is added by +/// Permission to read Test once, as typed `TestReadings` or as Test +/// `EncoderOffer`s. Only the weksa lib can make one: there is no public +/// constructor, so no code outside it can obtain those. This is not a seal on +/// corpus text: operator tools and `CorpusStore` can read any item, and the +/// guarded invariant is that the round path holds no Test reading. The one door +/// is added by /// convergence-freeze, which builds a `Checkpoint` from the convergence window /// over the run's persisted round records (convergence-test). /// `TestReadings::at_checkpoint` consumes it, so one `Checkpoint` buys one Test /// reading. One `Checkpoint` also offers each item at most once -/// (`EncoderOffer::for_checkpoint`). +/// (`EncoderOffer::for_checkpoint`), and an offer borrows its Checkpoint, so it +/// cannot outlive the reading that spends it. /// /// ``` /// fn f(c: &weksa::split::Checkpoint) -> u32 { c.round() } @@ -85,6 +93,13 @@ impl SplitScheme { /// let _ = weksa::split::Checkpoint::declare(1); /// ``` /// +/// The test constructor is not exported (its name today; the door scan in +/// `tests/checkpoint_door.rs` pins the shape under any name): +/// +/// ```compile_fail +/// let _ = weksa::split::Checkpoint::for_test(1); +/// ``` +/// /// ```compile_fail /// let _ = weksa::split::Checkpoint { round: 1, offered: Default::default() }; /// ``` diff --git a/crates/weksa/tests/checkpoint_door.rs b/crates/weksa/tests/checkpoint_door.rs new file mode 100644 index 0000000..2de6668 --- /dev/null +++ b/crates/weksa/tests/checkpoint_door.rs @@ -0,0 +1,101 @@ +//! The Checkpoint door is pinned by source shape, not by a name: no public item +//! of the lib can mint one, and nothing may derive, implement or default one. +//! `Checkpoint::for_test` is `cfg(test)`; a doctest names it today, and this +//! scan catches a rename. + +/// Why `src` lets code outside the lib make a `Checkpoint`, if it does. +fn door_leaks(split_rs: &str) -> Vec { + let mut leaks = Vec::new(); + let lines: Vec<&str> = split_rs.lines().collect(); + let mut in_impl = false; + let mut in_struct = false; + for (n, line) in lines.iter().enumerate() { + let t = line.trim(); + if t.starts_with("impl Checkpoint") { + in_impl = true; + } else if t.starts_with("impl") && t.contains("Checkpoint") { + leaks.push(format!("{}: trait impl: {t}", n + 1)); + } + if t.starts_with("pub struct Checkpoint") { + let derived = lines[..n] + .iter() + .rev() + .take_while(|l| l.trim().starts_with("#[") || l.trim().starts_with("///")) + .any(|l| l.contains("derive")); + if derived { + leaks.push(format!("{}: derive on Checkpoint", n + 1)); + } + in_struct = true; + continue; + } + if in_struct { + if t.starts_with('}') { + in_struct = false; + } else if t.starts_with("pub ") { + leaks.push(format!("{}: public field: {t}", n + 1)); + } + } + if in_impl && t.starts_with("pub ") && t.contains("fn ") { + let mints = t.contains("-> Self") || t.contains("-> Checkpoint"); + let test_only = lines[..n] + .iter() + .rev() + .take_while(|l| l.trim().starts_with("#[")) + .any(|l| l.contains("cfg(test)")); + if mints && !test_only { + leaks.push(format!("{}: pub constructor: {t}", n + 1)); + } + } + } + leaks +} + +#[test] +fn no_public_item_mints_a_checkpoint() { + let src = + std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/split.rs")).unwrap(); + assert_eq!(door_leaks(&src), Vec::::new()); + // No other module returns one either. + for entry in std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src")).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|e| e == "rs") && !path.ends_with("split.rs") { + let text = std::fs::read_to_string(&path).unwrap(); + let minting = text + .lines() + .filter(|l| l.contains("-> Checkpoint") || l.contains("-> Result Self { x }\n}\n"; + assert_eq!(door_leaks(stray).len(), 1); + assert_eq!(door_leaks("impl Default for Checkpoint {}\n").len(), 1); + assert_eq!(door_leaks("impl From for Checkpoint {}\n").len(), 1); + assert_eq!( + door_leaks("#[derive(Clone)]\npub struct Checkpoint {\n").len(), + 1 + ); + assert_eq!( + door_leaks("pub struct Checkpoint {\n pub round: u32,\n}\n").len(), + 1 + ); + assert!(door_leaks("pub struct Checkpoint {\n round: u32,\n}\n").is_empty()); +}