diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index a4f2749..4dd5e30 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -3,31 +3,54 @@ //! [`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, and nothing else: `mint` and -//! `encode` take none, so no run-time text can become instructions. +//! [`ENCODE_INSTRUCTIONS`], consts in this module; an encode prompt also lists +//! the inventory at the run's revision. +//! +//! Item blocks are data from the run's pinned corpus, reached only through +//! `RunStores`, the one public producer of `TrainItems` and `EncoderOffer` +//! (forged-item-text-in-prompt). A forged item in a scratch store is a different +//! run with a different corpus digest, visible in its record; nothing inside a +//! process its forger controls can stop it. use std::marker::PhantomData; use crate::corpus::CorpusItem; +use crate::inventory::{Inventory, InventoryEntry, OptionLabel, ValueKind}; use crate::packet::EntityId; -use crate::split::{Checkpoint, Split, SplitScheme}; +use crate::split::{Checkpoint, Split, SplitScheme, fnv1a64, hex16}; use crate::store::{CorpusStore, normalized}; /// The instructions a mint prompt starts with. golf-loop-en edits the wording /// here. pub const MINT_INSTRUCTIONS: &str = "Mint glosses from the lines below."; -/// The instructions an encode prompt starts with. -pub const ENCODE_INSTRUCTIONS: &str = "Encode the line below."; +/// The instructions an encode prompt starts with: the encoder's whole brief. +/// Fixed per run by `PROMPT_SOURCE_DIGEST`. +pub const ENCODE_INSTRUCTIONS: &str = "\ +Encode the line below as one packet draft, the meaning of the line and nothing else. +Answer with one JSON object and nothing else: no code fence, no commentary. +Fields: +- confidence: 0 to 1000. +- context: {setting, medium, participants, audience}; setting and medium are concept ids or null, participants and audience are lists of node ids. +- discourse: {speech_act, addressee, addressee_consent, features}; speech_act is a speech_act id, addressee a node id or null, addressee_consent always null. +- referents: a list of {id, kind, concept, entity, features}; kind is a referent_kind id, concept a concept id, entity an entity id from the entities line or null. +- predications: a list of {id, concept, frame, roles, features}; roles is a list of {role, filler}, filler a node id. +- relations: a list of {relation, from, to, features}. +- stance: {features}; the speaker's felt affect and restraint, never the affect the line displays. +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 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. /// /// ``` -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # 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 t = run.train().unwrap(); /// assert!(t.is_empty()); /// ``` /// @@ -36,17 +59,23 @@ pub const ENCODE_INSTRUCTIONS: &str = "Encode the line below."; /// ``` /// /// ```compile_fail -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # 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 t = run.train().unwrap(); /// let _u = t.clone(); /// ``` /// /// ```compile_fail /// fn s(_: &T) {} -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # 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 t = run.train().unwrap(); /// s(&t); /// ``` pub struct TrainItems { @@ -54,7 +83,7 @@ pub struct TrainItems { } impl TrainItems { - pub fn from_corpus(corpus: &CorpusStore) -> anyhow::Result { + pub(crate) fn from_corpus(corpus: &CorpusStore) -> anyhow::Result { let items = corpus .all()? .into_iter() @@ -109,9 +138,9 @@ pub enum OfferRefusal { /// use weksa::context::{EncoderOffer, TrainItems}; /// use weksa::split::Checkpoint; /// use weksa::{CorpusItem, score::TestReadings}; -/// fn f(mut c: Checkpoint, i: &CorpusItem, t: &TrainItems) { +/// fn f(mut c: Checkpoint, i: &CorpusItem, t: &TrainItems, inv: &weksa::Inventory) { /// if let Ok(o) = EncoderOffer::for_checkpoint(i, &mut c) { -/// let _ = weksa::context::Prompt::encode(&o, t); +/// let _ = weksa::context::Prompt::encode(&o, t, inv, 1); /// } /// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]); /// } @@ -121,10 +150,10 @@ pub enum OfferRefusal { /// use weksa::context::{EncoderOffer, TrainItems}; /// use weksa::split::Checkpoint; /// use weksa::{CorpusItem, score::TestReadings}; -/// fn f(mut c: Checkpoint, i: &CorpusItem, t: &TrainItems) { +/// fn f(mut c: Checkpoint, i: &CorpusItem, t: &TrainItems, inv: &weksa::Inventory) { /// let o = EncoderOffer::for_checkpoint(i, &mut c).unwrap(); /// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]); -/// let _ = weksa::context::Prompt::encode(&o, t); +/// let _ = weksa::context::Prompt::encode(&o, t, inv, 1); /// } /// ``` pub struct EncoderOffer<'cp> { @@ -134,8 +163,9 @@ pub struct EncoderOffer<'cp> { } impl<'cp> EncoderOffer<'cp> { - /// A round offers Train and Validation items. - pub fn for_round(item: &CorpusItem) -> Result, OfferRefusal> { + /// A round offers Train and Validation items. `RunStores::offer` and + /// `RunStores::offers` are the public producers. + pub(crate) fn for_round(item: &CorpusItem) -> Result, OfferRefusal> { match Self::gate(item)? { Split::Test => Err(OfferRefusal::TestBeforeCheckpoint), _ => Ok(Self::of(item)), @@ -188,9 +218,12 @@ impl<'cp> EncoderOffer<'cp> { /// constructors are [`Prompt::mint`] and [`Prompt::encode`]. /// /// ``` -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # 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 t = run.train().unwrap(); /// let p = weksa::context::Prompt::mint(&t); /// assert_eq!(p.text(), weksa::context::MINT_INSTRUCTIONS); /// ``` @@ -198,17 +231,23 @@ impl<'cp> EncoderOffer<'cp> { /// Instructions are not a parameter: /// /// ```compile_fail -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # 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 t = run.train().unwrap(); /// let p = weksa::context::Prompt::mint("x", &t); /// ``` /// /// ```compile_fail /// fn s(_: &T) {} -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # 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 t = run.train().unwrap(); /// let p = weksa::context::Prompt::mint(&t); /// s(&p); /// ``` @@ -238,6 +277,32 @@ fn block(item: &CorpusItem, prior: Option<&TrainItems>) -> String { lines.join("\n") } +/// One inventory line: the id and gloss, and for a feature or extension its +/// value kind (with the labels of an options feature) and the slots it sits in. +fn entry_line(entry: &InventoryEntry) -> String { + let mut line = format!("{}: {}", entry.id, entry.gloss); + let kind = entry.value_kind.as_ref().map(|kind| match kind { + ValueKind::Grade => "grade".to_string(), + ValueKind::Flag => "flag".to_string(), + ValueKind::Options(set) => { + let labels: Vec = Vec::::from(set.clone()) + .iter() + .map(OptionLabel::to_string) + .collect(); + format!("options {}", labels.join("|")) + } + }); + if let Some(kind) = kind { + let slots: Vec = entry.applies_to.iter().map(|s| format!("{s:?}")).collect(); + line.push_str(&format!(" [{kind}")); + if !slots.is_empty() { + line.push_str(&format!("; slots {}", slots.join(","))); + } + line.push(']'); + } + line +} + /// Printed in place of a prior turn whose text no live Train item holds. const ELIDED: &str = "[elided]"; @@ -253,19 +318,36 @@ impl Prompt { } /// [`ENCODE_INSTRUCTIONS`], the one offered item with its prior turns resolved - /// against `train`, and the entities a packet for it may cite. - pub fn encode(offer: &EncoderOffer, train: &TrainItems) -> Prompt { + /// against `train`, the entities a packet for it may cite, and the entries + /// of `inventory` live at `rev`. + pub fn encode( + offer: &EncoderOffer, + train: &TrainItems, + inventory: &Inventory, + rev: u32, + ) -> Prompt { let ids: Vec<&str> = offer.entities.iter().map(EntityId::as_str).collect(); + let mut text = format!( + "{ENCODE_INSTRUCTIONS}\n\n{}\n\nentities: {}\n\ninventory rev:{rev}", + block(&offer.item, Some(train)), + ids.join(", ") + ); + for entry in inventory.entries().filter(|e| e.live_at(rev)) { + text.push('\n'); + text.push_str(&entry_line(entry)); + } Prompt { - text: format!( - "{ENCODE_INSTRUCTIONS}\n\n{}\n\nentities: {}", - block(&offer.item, Some(train)), - ids.join(", ") - ), + text, items: vec![offer.item.id.clone()], } } + /// 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 { + hex16(fnv1a64(self.text.as_bytes())) + } + pub fn text(&self) -> &str { &self.text } @@ -303,6 +385,20 @@ mod tests { assert_eq!(check(&both), Some(OfferRefusal::Withdrawn)); } + #[test] + fn round_offer_gate_precedence() { + let round = |i: &CorpusItem| EncoderOffer::for_round(i).err(); + let mut no_split = item("item:c:2", "m", &group_in(Split::Test), true); + no_split.withdrawn = true; + // Withdrawn wins over NoSplit. + assert_eq!(round(&no_split), Some(OfferRefusal::Withdrawn)); + // A withdrawn Test item is Withdrawn, not TestBeforeCheckpoint. + let mut test = item("item:c:1", "x", &group_in(Split::Test), false); + assert_eq!(round(&test), Some(OfferRefusal::TestBeforeCheckpoint)); + test.withdrawn = true; + assert_eq!(round(&test), Some(OfferRefusal::Withdrawn)); + } + #[test] fn checkpoint_offers_each_item_once() { let mut cp = Checkpoint::for_test(1); diff --git a/crates/weksa/src/encode.rs b/crates/weksa/src/encode.rs new file mode 100644 index 0000000..430f360 --- /dev/null +++ b/crates/weksa/src/encode.rs @@ -0,0 +1,231 @@ +//! The encoder step: what a model's answer becomes. This is the one place JSON +//! is read (the xenos boundary): a model answers in JSON, `PacketDraft::parse` +//! reads it, and nothing is stored as JSON. The model 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. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::corpus::CorpusItemId; +use crate::packet::{ + Constraints, Context, Discourse, EncoderId, Grade, Packet, PacketId, PacketKind, Predication, + Provenance, ProvenanceSource, Referent, Relation, SCHEMA_VERSION, Stance, Trace, +}; +use crate::split::{fnv1a64, hex16}; + +/// The meaning fields of a packet, as a model writes them. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PacketDraft { + pub confidence: Grade, + #[serde(default)] + pub context: Context, + pub discourse: Discourse, + pub referents: Vec, + pub predications: Vec, + #[serde(default)] + pub relations: Vec, + #[serde(default)] + pub stance: Stance, +} + +/// Where an answer stopped being a draft. Position only: a parse message would +/// quote the offending token. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct DraftError { + pub line: usize, + pub column: usize, +} + +impl fmt::Display for DraftError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "not a packet draft at line {} column {}", + self.line, self.column + ) + } +} + +impl fmt::Debug for DraftError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl std::error::Error for DraftError {} + +impl PacketDraft { + pub fn parse(answer: &str) -> Result { + serde_json::from_str(answer.trim()).map_err(|e| DraftError { + line: e.line(), + column: e.column(), + }) + } + + /// The draft that says what `packet` says. + pub fn of(packet: &Packet) -> Self { + Self { + confidence: packet.provenance.confidence, + context: packet.context.clone(), + discourse: packet.discourse.clone(), + referents: packet.referents.clone(), + predications: packet.predications.clone(), + relations: packet.relations.clone(), + stance: packet.stance.clone(), + } + } + + /// The packet this draft becomes for `item` at inventory revision `rev`. + pub(crate) fn into_packet(self, item: &CorpusItemId, rev: u32, encoder: &EncoderId) -> Packet { + Packet { + id: packet_id(item, rev, encoder), + schema_version: SCHEMA_VERSION, + inventory_rev: rev, + kind: PacketKind::CorpusExample, + provenance: Provenance { + source: ProvenanceSource::ModelDecomposition, + corpus_item: item.clone(), + encoder: encoder.clone(), + confidence: self.confidence, + }, + context: self.context, + discourse: self.discourse, + referents: self.referents, + predications: self.predications, + relations: self.relations, + constraints: Constraints::default(), + extensions: vec![], + trace: Trace::default(), + stance: self.stance, + } + } +} + +/// `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()); + EncoderId::parse(&format!("encoder:{}", hex16(digest))).expect("an encoder id is well formed") +} + +/// `packet:`, a digest of the item, the inventory revision and the encoder. +pub fn packet_id(item: &CorpusItemId, rev: u32, encoder: &EncoderId) -> PacketId { + let digest = fnv1a64(format!("weksa-packet-v1:{item}:{rev}:{encoder}").as_bytes()); + PacketId::parse(&format!("packet:{}", hex16(digest))).expect("a packet id is well formed") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daodejing::packets; + + fn item() -> CorpusItemId { + CorpusItemId::parse("item:daodejing:1").unwrap() + } + + #[test] + fn ids_are_pinned_digests_of_their_inputs() { + let encoder = encoder_id("fake-model", "abcd"); + assert_eq!(encoder.as_str(), "encoder:4d35f2befb3108ec"); + let encoder = EncoderId::parse("encoder:0123456789abcdef").unwrap(); + assert_eq!( + packet_id(&item(), 3, &encoder).as_str(), + "packet:888ba17103e21a9d" + ); + // Each input moves the id. + assert_ne!( + encoder_id("other", "abcd"), + encoder_id("fake-model", "abcd") + ); + assert_ne!( + encoder_id("fake-model", "abce"), + encoder_id("fake-model", "abcd") + ); + let other = CorpusItemId::parse("item:daodejing:2").unwrap(); + let base = packet_id(&item(), 3, &encoder); + assert_ne!(packet_id(&other, 3, &encoder), base); + assert_ne!(packet_id(&item(), 4, &encoder), base); + assert_ne!(packet_id(&item(), 3, &encoder_id("m", "d")), base); + } + + #[test] + fn the_encoder_step_sets_everything_the_model_does_not_write() { + // A source packet with every non-meaning field set to something loud. + let mut source = packets().remove(0); + source.trace.notes = vec!["a note a model must not hand on".into()]; + source.constraints.deterministic = true; + source.extensions = vec![crate::packet::Extension { + key: "extension:1".parse().unwrap(), + value: crate::packet::FeatValue::Flag(true), + }]; + let draft = PacketDraft::of(&source); + let encoder = encoder_id("m", "d"); + let made = draft.clone().into_packet(&item(), 9, &encoder); + assert_eq!(made.id, packet_id(&item(), 9, &encoder)); + assert_eq!(made.schema_version, SCHEMA_VERSION); + assert_eq!(made.inventory_rev, 9); + assert_eq!(made.kind, PacketKind::CorpusExample); + assert_eq!(made.provenance.source, ProvenanceSource::ModelDecomposition); + assert_eq!(made.provenance.corpus_item, item()); + assert_eq!(made.provenance.encoder, encoder); + assert_eq!(made.provenance.confidence, source.provenance.confidence); + assert_eq!(made.constraints, Constraints::default()); + assert!(made.extensions.is_empty()); + assert_eq!(made.trace, Trace::default()); + // The meaning is the draft's, whole. + assert_eq!(PacketDraft::of(&made), draft); + } + + #[test] + fn parse_reads_a_draft_and_trims() { + let draft = PacketDraft::of(&packets()[0]); + let json = serde_json::to_string(&draft).unwrap(); + assert_eq!(PacketDraft::parse(&format!("\n {json} \n")).unwrap(), draft); + // The optional fields default. + let minimal = r#"{"confidence":500,"discourse":{"speech_act":null,"addressee":null,"addressee_consent":null,"features":[]},"referents":[],"predications":[]}"#; + let d = PacketDraft::parse(minimal).unwrap(); + assert_eq!(d.context, Context::default()); + assert!(d.relations.is_empty()); + assert_eq!(d.stance, Stance::default()); + } + + #[test] + fn parse_refuses_what_a_model_must_not_write() { + let draft = PacketDraft::of(&packets()[0]); + let mut value = serde_json::to_value(&draft).unwrap(); + for field in [ + "id", + "provenance", + "trace", + "inventory_rev", + "kind", + "extensions", + ] { + value[field] = serde_json::json!(null); + assert!(PacketDraft::parse(&value.to_string()).is_err(), "{field}"); + value.as_object_mut().unwrap().remove(field); + } + value["confidence"] = serde_json::json!(1001); + assert!(PacketDraft::parse(&value.to_string()).is_err()); + value.as_object_mut().unwrap().remove("confidence"); + assert!( + PacketDraft::parse(&value.to_string()).is_err(), + "confidence is required" + ); + } + + #[test] + 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!(format!("{err:?}"), err.to_string()); + let err = PacketDraft::parse(r#"{"confidence":500,"CANARYKEY":1}"#).unwrap_err(); + for text in [err.to_string(), format!("{err:?}")] { + assert!(!text.contains("CANARY"), "{text}"); + } + } +} diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index f8043a3..f61a9b6 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -6,12 +6,15 @@ pub mod context; pub mod corpus; pub mod daodejing; +pub mod encode; pub mod entity; pub mod form; pub mod inventory; pub mod lower; +pub mod model; pub mod packet; pub mod phones; +pub mod run; pub mod schema; pub mod score; pub mod speaker; @@ -20,6 +23,7 @@ pub mod store; pub mod validate; pub use corpus::{CorpusItem, CorpusItemId, NameSpan, Source, Turn}; +pub use encode::PacketDraft; pub use entity::{Entity, EntityOrigin, LanguageForms, Pronunciation}; pub use form::FormError; pub use inventory::{ @@ -27,10 +31,12 @@ pub use inventory::{ Slot, ValueKind, }; pub use lower::{EntityForms, LowerError, PhoneLine, TARGET_LANGUAGE, lower, phones, pronounce}; +pub use model::{AnswerFiles, ModelPort}; pub use packet::{ EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance, }; pub use phones::{Phone, PhoneInventory, SegmentError, Stress}; +pub use run::{RoundRecord, RunStores, round}; pub use score::{Embedder, ItemScore, OllamaEmbedder, Scorer, chrf}; pub use speaker::{Annotator, PairRelation, Party, Scene, Speaker}; pub use split::{Split, SplitScheme}; diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index d6832e7..5e7eb28 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -11,10 +11,11 @@ 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::{RoundOutcome, RunPaths, RunStores, round}; use weksa::score::ScoreInput; use weksa::{ - CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, PacketStore, Scorer, - SplitScheme, StoreError, lower, pronounce, validate, + AnswerFiles, CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, PacketStore, + Scorer, SplitScheme, StoreError, lower, pronounce, validate, }; #[derive(Parser)] @@ -66,6 +67,8 @@ enum Command { Score(ScoreArgs), /// Print the split of a corpus item. Split { item_key: String }, + #[command(subcommand)] + Loop(LoopCommand), /// Write the published JSON Schemas. Schema { #[arg(long, default_value = "schemas/cultnet")] @@ -73,6 +76,30 @@ enum Command { }, } +#[derive(Subcommand)] +enum LoopCommand { + /// Pin the global --corpus, --inventory and --entities as a run at ROOT. + Open { + root: PathBuf, + #[arg(long)] + label: String, + /// The id of the model that answers prompts. + #[arg(long)] + answerer: String, + #[arg(long, default_value = "qwen3-embedding:0.6b")] + embed_model: String, + }, + /// 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. + Round { + root: PathBuf, + #[arg(long, default_value = "http://127.0.0.1:11434")] + ollama_url: String, + }, +} + #[derive(Subcommand)] enum PacketCommand { /// Admit the nibu-proof-001 packet through `PacketStore::admit`. @@ -692,6 +719,45 @@ fn main() -> Result<()> { .context("no such item")?; println!("{}", split_text(&item)); } + Command::Loop(LoopCommand::Open { + root, + label, + answerer, + embed_model, + }) => { + let run = RunStores::create( + &root, + &label, + RunPaths { + corpus: &cli.corpus, + inventory: &cli.inventory, + entities: &cli.entities, + }, + &answerer, + &embed_model, + )?; + println!("opened run:{}", run.record().label); + } + Command::Loop(LoopCommand::Round { root, ollama_url }) => { + let unreadable = |_| anyhow::anyhow!("a store would not read"); + let mut run = RunStores::open(&root)?; + let mut files = AnswerFiles::new( + run.next_round_dir().map_err(unreadable)?, + run.record().model.clone(), + ); + let scorer = Scorer::new(OllamaEmbedder::new(&ollama_url, &run.record().embedder)); + match round(&mut run, &mut files, &scorer)? { + RoundOutcome::Unanswered { prompts } => { + for digest in &prompts { + println!("unanswered {digest}"); + } + let asked = run.offers().map_err(unreadable)?.len(); + println!("unanswered {} of {asked}", prompts.len()); + std::process::exit(3); + } + RoundOutcome::Recorded(r) => println!("{}", r.summary_line()), + } + } Command::Schema { out } => { std::fs::create_dir_all(&out)?; for (name, json) in weksa::schema::published() { diff --git a/crates/weksa/src/model.rs b/crates/weksa/src/model.rs new file mode 100644 index 0000000..2f83176 --- /dev/null +++ b/crates/weksa/src/model.rs @@ -0,0 +1,78 @@ +//! The loop's only door to a model. A model is the Claude session: it reads a +//! prompt file and writes an answer file, and a rerun picks the answer up. No +//! subprocess, network or binary lives here (golf-loop-in-claude). + +use std::fmt; +use std::io::ErrorKind; +use std::path::PathBuf; + +use crate::context::Prompt; + +/// A model answering [`Prompt`]s and nothing else. +pub trait ModelPort { + /// The id of the answering model, pinned by the run. + fn model(&self) -> &str; + fn answer(&mut self, prompt: &Prompt) -> Result; +} + +/// Why a prompt got no answer. Display names the kind only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PortError { + /// Nobody has answered yet. + Unanswered, + Io(ErrorKind), +} + +impl fmt::Display for PortError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PortError::Unanswered => f.write_str("the prompt is unanswered"), + PortError::Io(kind) => write!(f, "io error {kind:?}"), + } + } +} + +impl std::error::Error for PortError {} + +/// Answers from files in one directory: `.answer` is the reply to the +/// prompt whose digest is ``; a prompt with no answer is written to +/// `.prompt` for whoever answers. +pub struct AnswerFiles { + dir: PathBuf, + model: String, +} + +impl AnswerFiles { + pub fn new(dir: PathBuf, model: String) -> Self { + Self { dir, model } + } +} + +impl ModelPort for AnswerFiles { + fn model(&self) -> &str { + &self.model + } + + fn answer(&mut self, prompt: &Prompt) -> Result { + let io = |e: std::io::Error| PortError::Io(e.kind()); + let digest = prompt.digest(); + match std::fs::read(self.dir.join(format!("{digest}.answer"))) { + Ok(bytes) => { + return String::from_utf8(bytes).map_err(|_| PortError::Io(ErrorKind::InvalidData)); + } + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => return Err(io(e)), + } + std::fs::create_dir_all(&self.dir).map_err(io)?; + let path = self.dir.join(format!("{digest}.prompt")); + match std::fs::read(&path) { + Ok(held) if held == prompt.text().as_bytes() => {} + Ok(_) => return Err(PortError::Io(ErrorKind::AlreadyExists)), + Err(e) if e.kind() == ErrorKind::NotFound => { + std::fs::write(&path, prompt.text()).map_err(io)? + } + Err(e) => return Err(io(e)), + } + Err(PortError::Unanswered) + } +} diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs new file mode 100644 index 0000000..de6d846 --- /dev/null +++ b/crates/weksa/src/run.rs @@ -0,0 +1,738 @@ +//! One golf-loop run: the stores it pinned at open, the rounds it has recorded, +//! and the one writer of both (`RunStores::create` and, for rounds, `round`). +//! +//! A run pins its corpus and entity store files by canonical path and FNV-1a 64 +//! digest of their bytes. Every round reopens them only from that record and +//! refuses on drift, and every round record carries the digests. Nothing inside +//! a process whose writer controls the code can stop an item faked into a +//! scratch store; what this makes true is that a run over a scratch store is a +//! different run with a different corpus digest, visible in its record, and a +//! 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. + +use std::fmt; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use cultcache_rs::{CultCache, DatabaseEntry}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::context::{EncoderOffer, Prompt, TrainItems}; +use crate::corpus::{CorpusItem, CorpusItemId}; +use crate::encode::{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, hex16}; +use crate::store::{CorpusStore, EntityStore, InventoryStore, PacketStore, open_cache}; + +/// The digest of the prompt builder's source. Any edit to `context.rs`, +/// comments included, ends a run; code outside that file is not covered. +pub const PROMPT_SOURCE_DIGEST: u64 = fnv1a64(include_bytes!("context.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")); + +/// What a run pinned at open. Key `run: