golf-loop-round: the smallest loop that scores one round end to end #28
18 changed files with 2904 additions and 117 deletions
|
|
@ -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: serde::Serialize>(_: &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<Self> {
|
||||
pub(crate) fn from_corpus(corpus: &CorpusStore) -> anyhow::Result<Self> {
|
||||
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<EncoderOffer<'static>, 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<EncoderOffer<'static>, 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: serde::Serialize>(_: &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<String> = Vec::<OptionLabel>::from(set.clone())
|
||||
.iter()
|
||||
.map(OptionLabel::to_string)
|
||||
.collect();
|
||||
format!("options {}", labels.join("|"))
|
||||
}
|
||||
});
|
||||
if let Some(kind) = kind {
|
||||
let slots: Vec<String> = 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);
|
||||
|
|
|
|||
231
crates/weksa/src/encode.rs
Normal file
231
crates/weksa/src/encode.rs
Normal file
|
|
@ -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<Referent>,
|
||||
pub predications: Vec<Predication>,
|
||||
#[serde(default)]
|
||||
pub relations: Vec<Relation>,
|
||||
#[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<Self, DraftError> {
|
||||
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:<hex16>`, 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:<hex16>`, 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
78
crates/weksa/src/model.rs
Normal file
78
crates/weksa/src/model.rs
Normal file
|
|
@ -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<String, PortError>;
|
||||
}
|
||||
|
||||
/// 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: `<digest>.answer` is the reply to the
|
||||
/// prompt whose digest is `<digest>`; a prompt with no answer is written to
|
||||
/// `<digest>.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<String, PortError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
738
crates/weksa/src/run.rs
Normal file
738
crates/weksa/src/run.rs
Normal file
|
|
@ -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:<label>`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
|
||||
#[cultcache(type = "weksa.loop_run")]
|
||||
pub struct RunRecord {
|
||||
#[cultcache(key = 0)]
|
||||
pub label: String,
|
||||
/// Canonical absolute path of the corpus store.
|
||||
#[cultcache(key = 1)]
|
||||
pub corpus: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub inventory: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub entities: String,
|
||||
/// 16 hex of the corpus file's bytes.
|
||||
#[cultcache(key = 4)]
|
||||
pub corpus_digest: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub entity_digest: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub prompt_digest: String,
|
||||
#[cultcache(key = 7)]
|
||||
pub lowerer_digest: String,
|
||||
/// The answering model.
|
||||
#[cultcache(key = 8)]
|
||||
pub model: String,
|
||||
/// The embedder's model.
|
||||
#[cultcache(key = 9)]
|
||||
pub embedder: String,
|
||||
#[cultcache(key = 10)]
|
||||
pub chrf_version: String,
|
||||
}
|
||||
|
||||
/// Where a round refused: the first stage that did.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum Stage {
|
||||
Parse,
|
||||
Admit,
|
||||
Lower,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum Outcome {
|
||||
Scored { chrf: f64, meaning: Option<f64> },
|
||||
Refused(Stage),
|
||||
}
|
||||
|
||||
/// One model call: the prompt's digest and the item keys it held.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Call {
|
||||
pub prompt: String,
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// A Train item's row. Validation has no rows.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TrainRow {
|
||||
pub item: String,
|
||||
pub packet: Option<String>,
|
||||
pub outcome: Outcome,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct RefusalCounts {
|
||||
pub parse: u32,
|
||||
pub admit: u32,
|
||||
pub lower: u32,
|
||||
}
|
||||
|
||||
/// One scored round. It holds no text and no Test reading: per-item rows for
|
||||
/// Train only, Validation only as means and refusal counts by stage. Only
|
||||
/// `run::round` builds one.
|
||||
///
|
||||
/// ```
|
||||
/// fn f(r: &weksa::run::RoundRecord) -> u32 { r.n() }
|
||||
/// ```
|
||||
///
|
||||
/// Every field is given, so the literal below fails only on privacy; its twin
|
||||
/// is the same literal in the unit test `a_round_record_literal_compiles_in_the_lib`.
|
||||
///
|
||||
/// ```compile_fail,E0451
|
||||
/// use weksa::run::{RefusalCounts, RoundRecord};
|
||||
/// use weksa::score::SplitMean;
|
||||
/// fn f() -> RoundRecord {
|
||||
/// RoundRecord {
|
||||
/// n: 1,
|
||||
/// inventory_rev: 1,
|
||||
/// prompt_digest: String::new(),
|
||||
/// lowerer_digest: String::new(),
|
||||
/// corpus_digest: String::new(),
|
||||
/// entity_digest: String::new(),
|
||||
/// model: String::new(),
|
||||
/// embedder: String::new(),
|
||||
/// chrf_version: String::new(),
|
||||
/// calls: vec![],
|
||||
/// train: vec![],
|
||||
/// train_mean: SplitMean::default(),
|
||||
/// train_scored: 0,
|
||||
/// validation_mean: SplitMean::default(),
|
||||
/// validation_scored: 0,
|
||||
/// validation_refused: RefusalCounts::default(),
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq, JsonSchema, DatabaseEntry)]
|
||||
#[cultcache(type = "weksa.loop_round")]
|
||||
pub struct RoundRecord {
|
||||
#[cultcache(key = 0)]
|
||||
n: u32,
|
||||
#[cultcache(key = 1)]
|
||||
inventory_rev: u32,
|
||||
#[cultcache(key = 2)]
|
||||
prompt_digest: String,
|
||||
#[cultcache(key = 3)]
|
||||
lowerer_digest: String,
|
||||
#[cultcache(key = 4)]
|
||||
corpus_digest: String,
|
||||
#[cultcache(key = 5)]
|
||||
entity_digest: String,
|
||||
#[cultcache(key = 6)]
|
||||
model: String,
|
||||
#[cultcache(key = 7)]
|
||||
embedder: String,
|
||||
#[cultcache(key = 8)]
|
||||
chrf_version: String,
|
||||
#[cultcache(key = 9)]
|
||||
calls: Vec<Call>,
|
||||
#[cultcache(key = 10)]
|
||||
train: Vec<TrainRow>,
|
||||
#[cultcache(key = 11)]
|
||||
train_mean: SplitMean,
|
||||
#[cultcache(key = 12)]
|
||||
validation_mean: SplitMean,
|
||||
#[cultcache(key = 13)]
|
||||
validation_refused: RefusalCounts,
|
||||
/// Train items that lowered and scored; `train_mean` is over all of them
|
||||
/// and counts a refused item as 0.
|
||||
#[cultcache(key = 14)]
|
||||
train_scored: u32,
|
||||
/// Validation items that lowered and scored; `validation_mean` is over all
|
||||
/// of them and counts a refused item as 0, so refusing a weak item cannot
|
||||
/// raise it.
|
||||
#[cultcache(key = 15)]
|
||||
validation_scored: u32,
|
||||
}
|
||||
|
||||
impl RoundRecord {
|
||||
pub fn n(&self) -> u32 {
|
||||
self.n
|
||||
}
|
||||
pub fn inventory_rev(&self) -> u32 {
|
||||
self.inventory_rev
|
||||
}
|
||||
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
|
||||
}
|
||||
pub fn entity_digest(&self) -> &str {
|
||||
&self.entity_digest
|
||||
}
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
pub fn embedder(&self) -> &str {
|
||||
&self.embedder
|
||||
}
|
||||
pub fn chrf_version(&self) -> &str {
|
||||
&self.chrf_version
|
||||
}
|
||||
pub fn calls(&self) -> &[Call] {
|
||||
&self.calls
|
||||
}
|
||||
pub fn train(&self) -> &[TrainRow] {
|
||||
&self.train
|
||||
}
|
||||
pub fn train_mean(&self) -> &SplitMean {
|
||||
&self.train_mean
|
||||
}
|
||||
pub fn validation_mean(&self) -> &SplitMean {
|
||||
&self.validation_mean
|
||||
}
|
||||
pub fn validation_refused(&self) -> RefusalCounts {
|
||||
self.validation_refused
|
||||
}
|
||||
pub fn train_scored(&self) -> u32 {
|
||||
self.train_scored
|
||||
}
|
||||
pub fn validation_scored(&self) -> u32 {
|
||||
self.validation_scored
|
||||
}
|
||||
|
||||
/// The one line a recorded round prints. `n` is every item of the split,
|
||||
/// `scored` those that lowered; a mean counts a refused item as 0.
|
||||
pub fn summary_line(&self) -> String {
|
||||
let mean = |value: Option<f64>, places: usize| {
|
||||
value.map_or("none".to_string(), |v| format!("{v:.places$}"))
|
||||
};
|
||||
let refused = self
|
||||
.train
|
||||
.iter()
|
||||
.filter(|row| matches!(row.outcome, Outcome::Refused(_)))
|
||||
.count();
|
||||
let (t, v, x) = (
|
||||
&self.train_mean,
|
||||
&self.validation_mean,
|
||||
self.validation_refused,
|
||||
);
|
||||
format!(
|
||||
"round {} rev {} train n={} scored={} chrf={} meaning={} refused={refused} validation n={} scored={} chrf={} meaning={} refused parse={} admit={} lower={}",
|
||||
self.n,
|
||||
self.inventory_rev,
|
||||
t.count,
|
||||
self.train_scored,
|
||||
mean(t.chrf, 2),
|
||||
mean(t.meaning, 4),
|
||||
v.count,
|
||||
self.validation_scored,
|
||||
mean(v.chrf, 2),
|
||||
mean(v.meaning, 4),
|
||||
x.parse,
|
||||
x.admit,
|
||||
x.lower,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pinned store or id drifted from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DriftOf {
|
||||
Corpus,
|
||||
Entities,
|
||||
Prompts,
|
||||
Lowerer,
|
||||
Model,
|
||||
Embedder,
|
||||
}
|
||||
|
||||
/// A run's failure. Display and Debug name a kind, never a path, key, label or
|
||||
/// any other value the caller supplied.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RunError {
|
||||
/// `run.cc` already holds a run.
|
||||
Exists,
|
||||
/// `run.cc` holds no run.
|
||||
NoRun,
|
||||
/// A field that cannot be a run's: `label`, `model`, `embedder`, `path`.
|
||||
Malformed(&'static str),
|
||||
Drift(DriftOf),
|
||||
/// The inventory store holds no revision.
|
||||
NoInventory,
|
||||
Io(ErrorKind),
|
||||
/// A store or the run file would not read or write.
|
||||
Store,
|
||||
/// The embedder failed.
|
||||
Embed,
|
||||
}
|
||||
|
||||
impl fmt::Display for RunError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RunError::Exists => f.write_str("the root already holds a run"),
|
||||
RunError::NoRun => f.write_str("the root holds no run"),
|
||||
RunError::Malformed(what) => write!(f, "malformed {what}"),
|
||||
RunError::Drift(of) => write!(f, "drift in {of:?}"),
|
||||
RunError::NoInventory => f.write_str("the inventory holds no revision"),
|
||||
RunError::Io(kind) => write!(f, "io error {kind:?}"),
|
||||
RunError::Store => f.write_str("a store would not read or write"),
|
||||
RunError::Embed => f.write_str("the embedder failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RunError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RunError {}
|
||||
|
||||
/// The three stores a run pins.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RunPaths<'a> {
|
||||
pub corpus: &'a Path,
|
||||
pub inventory: &'a Path,
|
||||
pub entities: &'a Path,
|
||||
}
|
||||
|
||||
/// A run's open stores, reopened only from its record. The one public producer
|
||||
/// of [`TrainItems`] and [`EncoderOffer`], and the only writer of `run.cc`.
|
||||
pub struct RunStores {
|
||||
root: PathBuf,
|
||||
cache: CultCache,
|
||||
record: RunRecord,
|
||||
corpus: CorpusStore,
|
||||
inventory: InventoryStore,
|
||||
entities: EntityStore,
|
||||
}
|
||||
|
||||
fn file_digest(path: &str) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|b| hex16(fnv1a64(&b)))
|
||||
}
|
||||
|
||||
fn canonical(path: &Path) -> Result<String, RunError> {
|
||||
std::fs::canonicalize(path)
|
||||
.map_err(|e| RunError::Io(e.kind()))?
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.map_err(|_| RunError::Malformed("path"))
|
||||
}
|
||||
|
||||
fn run_cache(root: &Path) -> Result<CultCache, RunError> {
|
||||
open_cache(&root.join("run.cc"), |c| {
|
||||
c.register_entry_type::<RunRecord>()?;
|
||||
c.register_entry_type::<RoundRecord>()
|
||||
})
|
||||
.map_err(|_| RunError::Store)
|
||||
}
|
||||
|
||||
fn one_run(cache: &CultCache) -> Result<Option<RunRecord>, RunError> {
|
||||
Ok(cache
|
||||
.get_all::<RunRecord>()
|
||||
.map_err(|_| RunError::Store)?
|
||||
.into_iter()
|
||||
.next())
|
||||
}
|
||||
|
||||
impl RunStores {
|
||||
/// Pins the three stores at `root`, which must hold no run. A store file
|
||||
/// that does not exist is `Io(NotFound)`.
|
||||
pub fn create(
|
||||
root: &Path,
|
||||
label: &str,
|
||||
paths: RunPaths<'_>,
|
||||
model: &str,
|
||||
embedder: &str,
|
||||
) -> Result<Self, RunError> {
|
||||
if !is_slug(label) {
|
||||
return Err(RunError::Malformed("label"));
|
||||
}
|
||||
if !is_attestation(model) {
|
||||
return Err(RunError::Malformed("model"));
|
||||
}
|
||||
if !is_attestation(embedder) {
|
||||
return Err(RunError::Malformed("embedder"));
|
||||
}
|
||||
let mut cache = run_cache(root)?;
|
||||
if one_run(&cache)?.is_some() {
|
||||
return Err(RunError::Exists);
|
||||
}
|
||||
let record = RunRecord {
|
||||
label: label.to_string(),
|
||||
corpus: canonical(paths.corpus)?,
|
||||
inventory: canonical(paths.inventory)?,
|
||||
entities: canonical(paths.entities)?,
|
||||
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(),
|
||||
};
|
||||
let record = RunRecord {
|
||||
corpus_digest: file_digest(&record.corpus).ok_or(RunError::Store)?,
|
||||
entity_digest: file_digest(&record.entities).ok_or(RunError::Store)?,
|
||||
..record
|
||||
};
|
||||
cache
|
||||
.put(format!("run:{label}"), &record)
|
||||
.map_err(|_| RunError::Store)?;
|
||||
Self::with(root, cache, record)
|
||||
}
|
||||
|
||||
/// Reopens the run at `root` from its record, refusing when a pinned store
|
||||
/// or the prompt or lowerer source no longer matches.
|
||||
pub fn open(root: &Path) -> Result<Self, RunError> {
|
||||
if !root.join("run.cc").is_file() {
|
||||
return Err(RunError::NoRun);
|
||||
}
|
||||
let cache = run_cache(root)?;
|
||||
let record = one_run(&cache)?.ok_or(RunError::NoRun)?;
|
||||
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));
|
||||
}
|
||||
if file_digest(&record.corpus).as_ref() != Some(&record.corpus_digest) {
|
||||
return Err(RunError::Drift(DriftOf::Corpus));
|
||||
}
|
||||
if file_digest(&record.entities).as_ref() != Some(&record.entity_digest) {
|
||||
return Err(RunError::Drift(DriftOf::Entities));
|
||||
}
|
||||
Self::with(root, cache, record)
|
||||
}
|
||||
|
||||
fn with(root: &Path, cache: CultCache, record: RunRecord) -> Result<Self, RunError> {
|
||||
let store = |_| RunError::Store;
|
||||
Ok(Self {
|
||||
root: root.to_path_buf(),
|
||||
corpus: CorpusStore::open(Path::new(&record.corpus)).map_err(store)?,
|
||||
inventory: InventoryStore::open(Path::new(&record.inventory)).map_err(store)?,
|
||||
entities: EntityStore::open(Path::new(&record.entities)).map_err(store)?,
|
||||
cache,
|
||||
record,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn record(&self) -> &RunRecord {
|
||||
&self.record
|
||||
}
|
||||
|
||||
pub fn inventory(&self) -> &InventoryStore {
|
||||
&self.inventory
|
||||
}
|
||||
|
||||
/// The pinned entity store, for lowering and admission.
|
||||
pub fn entities(&self) -> &EntityStore {
|
||||
&self.entities
|
||||
}
|
||||
|
||||
/// The live Train items of the pinned corpus.
|
||||
pub fn train(&self) -> anyhow::Result<TrainItems> {
|
||||
TrainItems::from_corpus(&self.corpus)
|
||||
}
|
||||
|
||||
/// The offer for `key`; the inner error is the gate's refusal. An absent
|
||||
/// key is an error that names no key.
|
||||
pub fn offer(
|
||||
&self,
|
||||
key: &str,
|
||||
) -> anyhow::Result<Result<EncoderOffer<'static>, crate::context::OfferRefusal>> {
|
||||
let item = self
|
||||
.corpus
|
||||
.get(key)?
|
||||
.ok_or_else(|| anyhow::anyhow!("no such item"))?;
|
||||
Ok(EncoderOffer::for_round(&item))
|
||||
}
|
||||
|
||||
/// The live Train and Validation offers, in key order. Items the gate
|
||||
/// refuses are skipped and read no further.
|
||||
pub fn offers(&self) -> anyhow::Result<Vec<EncoderOffer<'static>>> {
|
||||
Ok(self
|
||||
.corpus
|
||||
.all()?
|
||||
.iter()
|
||||
.filter_map(|item| EncoderOffer::for_round(item).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The recorded rounds, in order.
|
||||
pub fn rounds(&self) -> anyhow::Result<Vec<RoundRecord>> {
|
||||
let mut rounds = self.cache.get_all::<RoundRecord>()?;
|
||||
rounds.sort_by_key(RoundRecord::n);
|
||||
Ok(rounds)
|
||||
}
|
||||
|
||||
/// `<root>/round-<n>` for the next unrecorded round `n`.
|
||||
pub fn next_round_dir(&self) -> anyhow::Result<PathBuf> {
|
||||
Ok(self
|
||||
.root
|
||||
.join(format!("round-{}", self.rounds()?.len() + 1)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a round came to.
|
||||
#[derive(Debug)]
|
||||
pub enum RoundOutcome {
|
||||
/// Prompts nobody has answered; nothing was written to the run or admitted.
|
||||
Unanswered {
|
||||
prompts: Vec<String>,
|
||||
},
|
||||
Recorded(RoundRecord),
|
||||
}
|
||||
|
||||
/// How one item fared: where it was refused, or the text it lowered to, with
|
||||
/// the key of the packet that was admitted for it.
|
||||
enum Fate {
|
||||
Refused(Stage, Option<String>),
|
||||
Lowered(String, String),
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `<root>/round-<n>/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
|
||||
/// 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<M: ModelPort, E: Embedder>(
|
||||
run: &mut RunStores,
|
||||
model: &mut M,
|
||||
scorer: &Scorer<E>,
|
||||
) -> Result<RoundOutcome, RunError> {
|
||||
let store = |_| RunError::Store;
|
||||
if model.model() != run.record.model {
|
||||
return Err(RunError::Drift(DriftOf::Model));
|
||||
}
|
||||
if scorer.embedder().model() != run.record.embedder {
|
||||
return Err(RunError::Drift(DriftOf::Embedder));
|
||||
}
|
||||
let inventory = run.inventory.inventory().map_err(store)?;
|
||||
let rev = inventory.latest().ok_or(RunError::NoInventory)?.n;
|
||||
|
||||
let train = run.train().map_err(store)?;
|
||||
let mut calls = Vec::new();
|
||||
let mut answers = Vec::new();
|
||||
let mut unanswered = Vec::new();
|
||||
for offer in run.offers().map_err(store)? {
|
||||
let prompt = Prompt::encode(&offer, &train, &inventory, rev);
|
||||
match model.answer(&prompt) {
|
||||
Ok(answer) => answers.push((offer.key().to_string(), answer)),
|
||||
Err(PortError::Unanswered) => unanswered.push(prompt.digest()),
|
||||
Err(PortError::Io(kind)) => return Err(RunError::Io(kind)),
|
||||
}
|
||||
calls.push(Call {
|
||||
prompt: prompt.digest(),
|
||||
items: prompt.item_keys().to_vec(),
|
||||
});
|
||||
}
|
||||
if !unanswered.is_empty() {
|
||||
return Ok(RoundOutcome::Unanswered {
|
||||
prompts: unanswered,
|
||||
});
|
||||
}
|
||||
|
||||
let dir = run.next_round_dir().map_err(store)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| RunError::Io(e.kind()))?;
|
||||
// The directory of an unrecorded round holds only a failed attempt's packets.
|
||||
discard_packets(&dir);
|
||||
let mut packets = PacketStore::open(&dir.join("packets.cc")).map_err(store)?;
|
||||
let encoder = encoder_id(model.model(), &run.record.prompt_digest);
|
||||
let mut items: Vec<CorpusItem> = Vec::new();
|
||||
let mut fates: Vec<Fate> = 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)?;
|
||||
let fate = match PacketDraft::parse(answer) {
|
||||
Err(_) => Fate::Refused(Stage::Parse, None),
|
||||
Ok(draft) => {
|
||||
let packet = draft.into_packet(&id, rev, &encoder);
|
||||
let packet_key = packet.id.to_string();
|
||||
match packets.admit(packet, &run.inventory, &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),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
items.push(item);
|
||||
fates.push(fate);
|
||||
}
|
||||
|
||||
// One score call over what lowered; none when nothing did.
|
||||
let lowered: Vec<(usize, &str)> = fates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, fate)| match fate {
|
||||
Fate::Lowered(text, _) => Some((i, text.as_str())),
|
||||
Fate::Refused(..) => None,
|
||||
})
|
||||
.collect();
|
||||
let inputs: Vec<ScoreInput> = lowered
|
||||
.iter()
|
||||
.map(|(i, text)| ScoreInput {
|
||||
reference: &items[*i].text,
|
||||
output: text,
|
||||
})
|
||||
.collect();
|
||||
let scores = if inputs.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
match scorer.score(&inputs) {
|
||||
Ok(scores) => scores,
|
||||
Err(_) => {
|
||||
discard_packets(&dir);
|
||||
return Err(RunError::Embed);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut score_at: Vec<Option<ItemScore>> = vec![None; items.len()];
|
||||
for ((i, _), score) in lowered.iter().zip(scores) {
|
||||
score_at[*i] = Some(score);
|
||||
}
|
||||
// An item that did not lower scores 0 in every mean.
|
||||
let nothing = ItemScore {
|
||||
chrf: 0.0,
|
||||
meaning: Some(0.0),
|
||||
};
|
||||
let scored: Vec<(&CorpusItem, ItemScore)> = items
|
||||
.iter()
|
||||
.zip(&score_at)
|
||||
.map(|(item, score)| (item, score.clone().unwrap_or_else(|| nothing.clone())))
|
||||
.collect();
|
||||
let means = aggregate(&scored);
|
||||
|
||||
let mut train_rows = Vec::new();
|
||||
let mut refused = RefusalCounts::default();
|
||||
let (mut train_scored, mut validation_scored) = (0, 0);
|
||||
for ((item, fate), score) in items.iter().zip(&fates).zip(&score_at) {
|
||||
let (outcome, packet) = match (fate, score) {
|
||||
(Fate::Lowered(_, key), Some(score)) => (
|
||||
Outcome::Scored {
|
||||
chrf: score.chrf,
|
||||
meaning: score.meaning,
|
||||
},
|
||||
Some(key.clone()),
|
||||
),
|
||||
(Fate::Refused(stage, key), _) => (Outcome::Refused(*stage), key.clone()),
|
||||
(Fate::Lowered(..), None) => return Err(RunError::Store),
|
||||
};
|
||||
match (SplitScheme::V1.live(item), fate) {
|
||||
(Some(Split::Train), fate) => {
|
||||
train_scored += u32::from(matches!(fate, Fate::Lowered(..)));
|
||||
train_rows.push(TrainRow {
|
||||
item: item.id.clone(),
|
||||
packet,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
(Some(Split::Validation), Fate::Lowered(..)) => validation_scored += 1,
|
||||
(Some(Split::Validation), Fate::Refused(stage, _)) => match stage {
|
||||
Stage::Parse => refused.parse += 1,
|
||||
Stage::Admit => refused.admit += 1,
|
||||
Stage::Lower => refused.lower += 1,
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let n = run.rounds().map_err(store)?.len() as u32 + 1;
|
||||
let record = RoundRecord {
|
||||
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(),
|
||||
embedder: run.record.embedder.clone(),
|
||||
chrf_version: run.record.chrf_version.clone(),
|
||||
calls,
|
||||
train: train_rows,
|
||||
train_mean: means.train,
|
||||
validation_mean: means.validation,
|
||||
validation_refused: refused,
|
||||
train_scored,
|
||||
validation_scored,
|
||||
};
|
||||
run.cache
|
||||
.put(format!("run:{}:round:{n}", run.record.label), &record)
|
||||
.map_err(store)?;
|
||||
Ok(RoundOutcome::Recorded(record))
|
||||
}
|
||||
|
||||
/// Removes a round directory's packet store and its lock.
|
||||
fn discard_packets(dir: &Path) {
|
||||
for name in ["packets.cc", "packets.cc.lock"] {
|
||||
let _ = std::fs::remove_file(dir.join(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The twin of the `RoundRecord` literal in its compile_fail doctest: the
|
||||
/// same fields, compiling here because the fields are private to this module.
|
||||
#[test]
|
||||
fn a_round_record_literal_compiles_in_the_lib() {
|
||||
let r = RoundRecord {
|
||||
n: 1,
|
||||
inventory_rev: 1,
|
||||
prompt_digest: String::new(),
|
||||
lowerer_digest: String::new(),
|
||||
corpus_digest: String::new(),
|
||||
entity_digest: String::new(),
|
||||
model: String::new(),
|
||||
embedder: String::new(),
|
||||
chrf_version: String::new(),
|
||||
calls: vec![],
|
||||
train: vec![],
|
||||
train_mean: SplitMean::default(),
|
||||
train_scored: 0,
|
||||
validation_mean: SplitMean::default(),
|
||||
validation_scored: 0,
|
||||
validation_refused: RefusalCounts::default(),
|
||||
};
|
||||
assert_eq!(r.n(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ use crate::corpus::CorpusItem;
|
|||
use crate::entity::Entity;
|
||||
use crate::inventory::{InventoryEntry, InventoryRevision};
|
||||
use crate::packet::Packet;
|
||||
use crate::run::{RoundRecord, RunRecord};
|
||||
use crate::speaker::{Scene, Speaker};
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use schemars::{JsonSchema, schema_for};
|
||||
|
|
@ -24,5 +25,7 @@ pub fn published() -> Vec<(String, String)> {
|
|||
published_for::<Entity>(),
|
||||
published_for::<Speaker>(),
|
||||
published_for::<Scene>(),
|
||||
published_for::<RunRecord>(),
|
||||
published_for::<RoundRecord>(),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ mod embed;
|
|||
mod words;
|
||||
|
||||
use anyhow::{Result, ensure};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use chrf::{CHRF_VERSION, chrf};
|
||||
pub use diagnostics::{OrderCoverage, PairChrf, SplitLeak, WorstItem, leak_diagnostic};
|
||||
|
|
@ -102,7 +104,7 @@ impl<E: Embedder> Scorer<E> {
|
|||
}
|
||||
|
||||
/// Mean scores of one derived split.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SplitMean {
|
||||
pub count: usize,
|
||||
/// `None` when the split has no items.
|
||||
|
|
|
|||
|
|
@ -24,15 +24,22 @@ pub enum SplitScheme {
|
|||
V1,
|
||||
}
|
||||
|
||||
pub fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
pub const fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= u64::from(*b);
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
h ^= bytes[i] as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
i += 1;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// 16 lowercase hex digits, zero-padded: how a digest is written everywhere.
|
||||
pub(crate) fn hex16(n: u64) -> String {
|
||||
format!("{n:016x}")
|
||||
}
|
||||
|
||||
impl SplitScheme {
|
||||
/// 0..100, FNV-1a 64 of `weksa-split-v1:` + group, mod 100.
|
||||
pub fn bucket(self, group: &str) -> u64 {
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ fn malformed(key: &str, why: &'static str) -> StoreError {
|
|||
.into()
|
||||
}
|
||||
|
||||
fn open_cache(
|
||||
pub(crate) fn open_cache(
|
||||
path: &Path,
|
||||
register: impl FnOnce(&mut CultCache) -> anyhow::Result<()>,
|
||||
) -> anyhow::Result<CultCache> {
|
||||
|
|
|
|||
|
|
@ -309,6 +309,8 @@ fn schema_writes_the_published_files() {
|
|||
"weksa.interlingua_packet.schema.json",
|
||||
"weksa.inventory_entry.schema.json",
|
||||
"weksa.inventory_revision.schema.json",
|
||||
"weksa.loop_round.schema.json",
|
||||
"weksa.loop_run.schema.json",
|
||||
"weksa.scene.schema.json",
|
||||
"weksa.speaker.schema.json"
|
||||
]
|
||||
|
|
@ -1313,3 +1315,145 @@ fn pronunciation_language_is_never_echoed() {
|
|||
);
|
||||
assert!(!text.to_lowercase().contains("sentinel"), "{text}");
|
||||
}
|
||||
|
||||
/// Copies of the Dao De Jing stores where `weksa()` looks for them.
|
||||
fn dao_stores(dir: &std::path::Path) {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/daodejing");
|
||||
for (from, to) in [
|
||||
("corpus.cc", "c.cc"),
|
||||
("inventory.cc", "i.cc"),
|
||||
("entity.cc", "e.cc"),
|
||||
] {
|
||||
std::fs::copy(src.join(from), dir.join(to)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_open_and_unanswered_round() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
dao_stores(d.path());
|
||||
let open = |root: &str| {
|
||||
weksa(
|
||||
d.path(),
|
||||
&[
|
||||
"loop",
|
||||
"open",
|
||||
root,
|
||||
"--label",
|
||||
"smoke",
|
||||
"--answerer",
|
||||
"fake-model",
|
||||
],
|
||||
)
|
||||
};
|
||||
let o = open("canaryroot");
|
||||
assert!(o.status.success(), "{}", out(&o));
|
||||
assert_eq!(out(&o), "opened run:smoke\n");
|
||||
|
||||
// A second open fails and names neither the root nor a path.
|
||||
let again = open("canaryroot");
|
||||
assert!(!again.status.success());
|
||||
let text = out(&again);
|
||||
assert!(text.contains("already holds a run"), "{text}");
|
||||
for echoed in ["canaryroot", d.path().to_str().unwrap()] {
|
||||
assert!(!text.contains(echoed), "{text}");
|
||||
}
|
||||
|
||||
// Round 1 is unanswered: three digests, a count, exit 3, no round recorded.
|
||||
let round = || weksa(d.path(), &["loop", "round", "canaryroot"]);
|
||||
let first = round();
|
||||
assert_eq!(first.status.code(), Some(3), "{}", out(&first));
|
||||
let text = out(&first);
|
||||
let digests: Vec<&str> = text
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("unanswered "))
|
||||
.filter(|d| d.len() == 16)
|
||||
.collect();
|
||||
assert_eq!(digests.len(), 3, "{text}");
|
||||
assert!(text.ends_with("unanswered 3 of 3\n"), "{text}");
|
||||
assert!(!text.contains("canaryroot"), "{text}");
|
||||
for digest in &digests {
|
||||
assert!(
|
||||
d.path()
|
||||
.join(format!("canaryroot/round-1/{digest}.prompt"))
|
||||
.is_file()
|
||||
);
|
||||
}
|
||||
|
||||
// Answers that are not drafts record a round of refusals, with no embed call.
|
||||
for digest in &digests {
|
||||
std::fs::write(
|
||||
d.path().join(format!("canaryroot/round-1/{digest}.answer")),
|
||||
"not json",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let second = 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"
|
||||
);
|
||||
|
||||
// The global store flags are not read by loop round: a drifted store is refused.
|
||||
let mut entities = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||
entities.put(common::ent("kay", "en-US", &["Kay"])).unwrap();
|
||||
let drifted = round();
|
||||
assert!(!drifted.status.success());
|
||||
assert!(
|
||||
out(&drifted).contains("drift in Entities"),
|
||||
"{}",
|
||||
out(&drifted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_errors_echo_no_input() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
dao_stores(d.path());
|
||||
let o = weksa(d.path(), &["loop", "round", "canaryroot"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(out(&o).contains("holds no run"), "{}", out(&o));
|
||||
for args in [
|
||||
vec![
|
||||
"loop",
|
||||
"open",
|
||||
"canaryroot",
|
||||
"--label",
|
||||
"CANARYLABEL",
|
||||
"--answerer",
|
||||
"m",
|
||||
],
|
||||
vec![
|
||||
"loop",
|
||||
"open",
|
||||
"canaryroot",
|
||||
"--label",
|
||||
"ok",
|
||||
"--answerer",
|
||||
"CANARY\n",
|
||||
],
|
||||
] {
|
||||
let o = weksa(d.path(), &args);
|
||||
assert!(!o.status.success());
|
||||
assert!(!out(&o).to_uppercase().contains("CANARY"), "{}", out(&o));
|
||||
}
|
||||
// A store path that does not exist.
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_weksa"))
|
||||
.current_dir(d.path())
|
||||
.args([
|
||||
"--corpus",
|
||||
"./canarystore.cc",
|
||||
"loop",
|
||||
"open",
|
||||
"canaryroot",
|
||||
"--label",
|
||||
"ok",
|
||||
"--answerer",
|
||||
"m",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(!out(&o).to_uppercase().contains("CANARY"), "{}", out(&o));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,28 @@ pub fn entities_in(dir: &std::path::Path) -> EntityStore {
|
|||
store
|
||||
}
|
||||
|
||||
/// A run over the stores `world()` and `corpus()` keep in `dir`: `c.cc`,
|
||||
/// `i.cc` and `entities.cc`. Read at the call, so create it after the items.
|
||||
pub fn run_over(dir: &std::path::Path) -> weksa::run::RunStores {
|
||||
for name in ["c.cc", "i.cc", "entities.cc"] {
|
||||
if !dir.join(name).exists() {
|
||||
std::fs::write(dir.join(name), [0x90]).unwrap();
|
||||
}
|
||||
}
|
||||
weksa::run::RunStores::create(
|
||||
&dir.join("run"),
|
||||
"t",
|
||||
weksa::run::RunPaths {
|
||||
corpus: &dir.join("c.cc"),
|
||||
inventory: &dir.join("i.cc"),
|
||||
entities: &dir.join("entities.cc"),
|
||||
},
|
||||
"fake-model",
|
||||
"bag",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn item_in(key: &str, text: &str, group: &str, source: Source) -> CorpusItem {
|
||||
CorpusItem {
|
||||
id: key.into(),
|
||||
|
|
@ -331,3 +353,25 @@ pub fn good() -> Packet {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic bag-of-letters embedder, so cosine differs with the text.
|
||||
pub struct Bag;
|
||||
|
||||
impl weksa::Embedder for Bag {
|
||||
fn model(&self) -> &str {
|
||||
"bag"
|
||||
}
|
||||
fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
Ok(texts
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut v = vec![0.0; 27];
|
||||
v[26] = 1.0;
|
||||
for c in t.chars().filter(char::is_ascii_alphabetic) {
|
||||
v[(c.to_ascii_lowercase() as u8 - b'a') as usize] += 1.0;
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
mod common;
|
||||
|
||||
use common::{cast, ent, entities_in, group_in_bucket, item_in, model, world};
|
||||
use common::{cast, ent, entities_in, group_in_bucket, item_in, model, run_over, world};
|
||||
use weksa::context::{
|
||||
ENCODE_INSTRUCTIONS as ENCODE, EncoderOffer, MINT_INSTRUCTIONS as MINT, OfferRefusal, Prompt,
|
||||
TrainItems,
|
||||
ENCODE_INSTRUCTIONS as ENCODE, MINT_INSTRUCTIONS as MINT, OfferRefusal, Prompt,
|
||||
};
|
||||
use weksa::run::RunStores;
|
||||
use weksa::{
|
||||
CorpusItem, CorpusItemId, CorpusStore, NameSpan, PacketId, PacketStore, StoreError,
|
||||
StoreRefusal, Turn,
|
||||
|
|
@ -13,6 +13,7 @@ use weksa::{
|
|||
struct Corpus {
|
||||
_dir: tempfile::TempDir,
|
||||
store: CorpusStore,
|
||||
run: RunStores,
|
||||
}
|
||||
|
||||
/// One live item per split, a withdrawn Train item and a model-written
|
||||
|
|
@ -69,7 +70,12 @@ fn corpus() -> Corpus {
|
|||
&group_in_bucket(90),
|
||||
model(),
|
||||
));
|
||||
Corpus { _dir: dir, store }
|
||||
let run = run_over(dir.path());
|
||||
Corpus {
|
||||
_dir: dir,
|
||||
store,
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(c: &Corpus, key: &str) -> CorpusItem {
|
||||
|
|
@ -79,7 +85,7 @@ fn get(c: &Corpus, key: &str) -> CorpusItem {
|
|||
#[test]
|
||||
fn train_items_hold_only_live_train() {
|
||||
let c = corpus();
|
||||
let t = TrainItems::from_corpus(&c.store).unwrap();
|
||||
let t = c.run.train().unwrap();
|
||||
assert_eq!(t.keys().collect::<Vec<_>>(), ["item:a:1"]);
|
||||
assert_eq!((t.len(), t.is_empty()), (1, false));
|
||||
}
|
||||
|
|
@ -98,15 +104,14 @@ fn train_items_are_in_key_order() {
|
|||
.add(item_in(key, text, &group_in_bucket(10), cast()), &entities)
|
||||
.unwrap();
|
||||
}
|
||||
let t = TrainItems::from_corpus(&store).unwrap();
|
||||
let t = run_over(dir.path()).train().unwrap();
|
||||
assert_eq!(
|
||||
t.keys().collect::<Vec<_>>(),
|
||||
["item:a:1", "item:m:1", "item:z:1"]
|
||||
);
|
||||
assert_eq!((t.len(), t.is_empty()), (3, false));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let none = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
|
||||
let none = TrainItems::from_corpus(&none).unwrap();
|
||||
let none = run_over(dir.path()).train().unwrap();
|
||||
assert_eq!((none.len(), none.is_empty()), (0, true));
|
||||
assert_eq!(Prompt::mint(&none).text(), MINT);
|
||||
assert!(Prompt::mint(&none).item_keys().is_empty());
|
||||
|
|
@ -115,7 +120,7 @@ fn train_items_are_in_key_order() {
|
|||
#[test]
|
||||
fn mint_prompt_holds_train_text_only() {
|
||||
let c = corpus();
|
||||
let t = TrainItems::from_corpus(&c.store).unwrap();
|
||||
let t = c.run.train().unwrap();
|
||||
let p = Prompt::mint(&t);
|
||||
assert!(p.text().starts_with(MINT));
|
||||
assert!(p.text().contains("alphamarker"));
|
||||
|
|
@ -142,7 +147,8 @@ fn prompts_render_deterministically() {
|
|||
&entities,
|
||||
)
|
||||
.unwrap();
|
||||
let t = TrainItems::from_corpus(&store).unwrap();
|
||||
let run = run_over(dir.path());
|
||||
let t = run.train().unwrap();
|
||||
assert_eq!(
|
||||
Prompt::mint(&t).text(),
|
||||
format!(
|
||||
|
|
@ -151,39 +157,45 @@ fn prompts_render_deterministically() {
|
|||
);
|
||||
assert_eq!(Prompt::mint(&t).item_keys(), ["item:a:1", "item:a:2"]);
|
||||
|
||||
let offer = EncoderOffer::for_round(&store.get("item:a:2").unwrap().unwrap()).unwrap();
|
||||
let offer = run.offer("item:a:2").unwrap().unwrap();
|
||||
let inv = run.inventory().inventory().unwrap();
|
||||
assert_eq!(
|
||||
Prompt::encode(&offer, &t).text(),
|
||||
format!("{ENCODE}\n\n[item:a:2] entity:nibu: Second line.\n\nentities: entity:nibu")
|
||||
Prompt::encode(&offer, &t, &inv, 0).text(),
|
||||
format!(
|
||||
"{ENCODE}\n\n[item:a:2] entity:nibu: Second line.\n\nentities: entity:nibu\n\ninventory rev:0"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Prompt::encode(&offer, &t, &inv, 0).item_keys(),
|
||||
["item:a:2"]
|
||||
);
|
||||
assert_eq!(Prompt::encode(&offer, &t).item_keys(), ["item:a:2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_offer_split_gate() {
|
||||
let c = corpus();
|
||||
let train = get(&c, "item:a:1");
|
||||
let validation = get(&c, "item:b:1");
|
||||
let test = get(&c, "item:c:1");
|
||||
let no_split = get(&c, "item:c:2");
|
||||
let withdrawn = get(&c, "item:a:2");
|
||||
assert!(withdrawn.withdrawn);
|
||||
let round = |i: &CorpusItem| EncoderOffer::for_round(i).err();
|
||||
let round = |key: &str| c.run.offer(key).unwrap().err();
|
||||
assert_eq!(round("item:a:1"), None);
|
||||
assert_eq!(round("item:b:1"), None);
|
||||
assert_eq!(round("item:c:1"), Some(OfferRefusal::TestBeforeCheckpoint));
|
||||
assert_eq!(round("item:c:2"), Some(OfferRefusal::NoSplit));
|
||||
assert_eq!(round("item:a:2"), Some(OfferRefusal::Withdrawn));
|
||||
assert!(get(&c, "item:a:2").withdrawn);
|
||||
let absent = c.run.offer("item:z:9").err().unwrap().to_string();
|
||||
assert!(!absent.contains("item:z:9"), "an absent key is not echoed");
|
||||
}
|
||||
|
||||
assert_eq!(round(&train), None);
|
||||
assert_eq!(round(&validation), None);
|
||||
assert_eq!(round(&test), Some(OfferRefusal::TestBeforeCheckpoint));
|
||||
assert_eq!(round(&no_split), Some(OfferRefusal::NoSplit));
|
||||
assert_eq!(round(&withdrawn), Some(OfferRefusal::Withdrawn));
|
||||
|
||||
// Withdrawn wins over NoSplit.
|
||||
let mut both = no_split.clone();
|
||||
both.withdrawn = true;
|
||||
assert_eq!(round(&both), Some(OfferRefusal::Withdrawn));
|
||||
// A withdrawn Test item is Withdrawn, not TestBeforeCheckpoint.
|
||||
let mut test_gone = test.clone();
|
||||
test_gone.withdrawn = true;
|
||||
assert_eq!(round(&test_gone), Some(OfferRefusal::Withdrawn));
|
||||
#[test]
|
||||
fn offers_are_the_live_train_and_validation_items_in_key_order() {
|
||||
let c = corpus();
|
||||
let keys: Vec<String> = c
|
||||
.run
|
||||
.offers()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|o| o.key().to_string())
|
||||
.collect();
|
||||
assert_eq!(keys, ["item:a:1", "item:b:1"]);
|
||||
}
|
||||
|
||||
fn span(slug: &str, start: u32, end: u32) -> NameSpan {
|
||||
|
|
@ -219,13 +231,22 @@ fn cited_world() -> (Corpus, CorpusItem, CorpusItem) {
|
|||
sibling.names = vec![span("someone", 0, 7)];
|
||||
store.add(mine.clone(), &entities).unwrap();
|
||||
store.add(sibling.clone(), &entities).unwrap();
|
||||
(Corpus { _dir: dir, store }, mine, sibling)
|
||||
let run = run_over(dir.path());
|
||||
(
|
||||
Corpus {
|
||||
_dir: dir,
|
||||
store,
|
||||
run,
|
||||
},
|
||||
mine,
|
||||
sibling,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_offer_entities_are_the_items_own() {
|
||||
let (c, mine, sibling) = cited_world();
|
||||
let t = TrainItems::from_corpus(&c.store).unwrap();
|
||||
let (c, mine, _sibling) = cited_world();
|
||||
let t = c.run.train().unwrap();
|
||||
assert_eq!(
|
||||
mine.cited_entities()
|
||||
.map(|e| e.as_str())
|
||||
|
|
@ -238,7 +259,7 @@ fn encoder_offer_entities_are_the_items_own() {
|
|||
],
|
||||
"names, speaker, prior-turn speakers, repeats kept"
|
||||
);
|
||||
let offer = EncoderOffer::for_round(&mine).unwrap();
|
||||
let offer = c.run.offer("item:a:1").unwrap().unwrap();
|
||||
let ids: Vec<&str> = offer.entities().iter().map(|e| e.as_str()).collect();
|
||||
assert_eq!(ids, ["entity:brokkr", "entity:hero", "entity:kay"]);
|
||||
assert!(
|
||||
|
|
@ -246,15 +267,16 @@ fn encoder_offer_entities_are_the_items_own() {
|
|||
"a sibling's entity is not the item's"
|
||||
);
|
||||
assert_eq!(offer.key(), "item:a:1");
|
||||
let p = Prompt::encode(&offer, &t);
|
||||
let inv = c.run.inventory().inventory().unwrap();
|
||||
let p = Prompt::encode(&offer, &t, &inv, 0);
|
||||
assert!(
|
||||
p.text()
|
||||
.ends_with("entities: entity:brokkr, entity:hero, entity:kay")
|
||||
.ends_with("entities: entity:brokkr, entity:hero, entity:kay\n\ninventory rev:0")
|
||||
);
|
||||
assert!(p.text().contains("[item:a:1]"));
|
||||
assert!(!p.text().contains("item:a:2") && !p.text().contains("Someone waits"));
|
||||
assert_eq!(p.item_keys(), ["item:a:1"]);
|
||||
let sib = EncoderOffer::for_round(&sibling).unwrap();
|
||||
let sib = c.run.offer("item:a:2").unwrap().unwrap();
|
||||
assert_eq!(sib.entities().len(), 2);
|
||||
}
|
||||
|
||||
|
|
@ -275,7 +297,10 @@ fn offer_and_store_agree() {
|
|||
text: "Where?".into(),
|
||||
}];
|
||||
w.corpus.add(item.clone(), &w.entities).unwrap();
|
||||
let offer = EncoderOffer::for_round(&item).unwrap();
|
||||
let offer = run_over(w.dir.path())
|
||||
.offer("item:nibu:2")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let mut packets = PacketStore::open(&w.dir.path().join("p.cc")).unwrap();
|
||||
let all = ["nibu", "hero", "kay", "brokkr", "player", "s", "someone"];
|
||||
|
|
@ -348,7 +373,8 @@ fn prior_turns_print_only_text_a_live_train_item_holds() {
|
|||
add("item:c:1", "testsecret line", &group_in_bucket(90), vec![]);
|
||||
store.withdraw("item:a:1").unwrap();
|
||||
|
||||
let t = TrainItems::from_corpus(&store).unwrap();
|
||||
let run = run_over(dir.path());
|
||||
let t = run.train().unwrap();
|
||||
let mint = Prompt::mint(&t);
|
||||
for secret in ["withdrawnsecret", "testsecret", "validation reply"] {
|
||||
assert!(!mint.text().contains(secret), "mint printed {secret}");
|
||||
|
|
@ -358,9 +384,9 @@ fn prior_turns_print_only_text_a_live_train_item_holds() {
|
|||
"mint prints no prior turns"
|
||||
);
|
||||
|
||||
let inv = run.inventory().inventory().unwrap();
|
||||
let encode = |key: &str| {
|
||||
let item = store.get(key).unwrap().unwrap();
|
||||
Prompt::encode(&EncoderOffer::for_round(&item).unwrap(), &t)
|
||||
Prompt::encode(&run.offer(key).unwrap().unwrap(), &t, &inv, 0)
|
||||
.text()
|
||||
.to_string()
|
||||
};
|
||||
|
|
@ -397,8 +423,10 @@ fn encode_with_prior(train_text: &str, prior_text: &str) -> (String, Result<(),
|
|||
text: prior_text.into(),
|
||||
}];
|
||||
store.add(reply.clone(), &entities).unwrap();
|
||||
let t = TrainItems::from_corpus(&store).unwrap();
|
||||
let p = Prompt::encode(&EncoderOffer::for_round(&reply).unwrap(), &t);
|
||||
let run = run_over(dir.path());
|
||||
let t = run.train().unwrap();
|
||||
let inv = run.inventory().inventory().unwrap();
|
||||
let p = Prompt::encode(&run.offer("item:a:2").unwrap().unwrap(), &t, &inv, 0);
|
||||
let text = p.text().to_string();
|
||||
// The store's own rule, as the oracle for key equality; after the prompt is
|
||||
// built, because an admitted probe would itself become a held line.
|
||||
|
|
|
|||
1066
crates/weksa/tests/golf_loop.rs
Normal file
1066
crates/weksa/tests/golf_loop.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@ use std::net::TcpListener;
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use common::{cast, group_in_bucket, item_in, model};
|
||||
use common::{Bag, cast, group_in_bucket, item_in, model};
|
||||
use serde_json::Value;
|
||||
use weksa::CorpusItem;
|
||||
use weksa::score::{
|
||||
|
|
@ -71,28 +71,6 @@ impl Embedder for Recorder {
|
|||
}
|
||||
}
|
||||
|
||||
/// A deterministic bag-of-letters embedder, so cosine differs with the text.
|
||||
struct Bag;
|
||||
|
||||
impl Embedder for Bag {
|
||||
fn model(&self) -> &str {
|
||||
"bag"
|
||||
}
|
||||
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||
Ok(texts
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut v = vec![0.0; 27];
|
||||
v[26] = 1.0;
|
||||
for c in t.chars().filter(char::is_ascii_alphabetic) {
|
||||
v[(c.to_ascii_lowercase() as u8 - b'a') as usize] += 1.0;
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn score_one<E: Embedder>(scorer: &Scorer<E>, reference: &str, output: &str) -> ItemScore {
|
||||
scorer
|
||||
.score(&[ScoreInput { reference, output }])
|
||||
|
|
|
|||
|
|
@ -403,8 +403,9 @@ One line per cut; specs exist for the cuts about to run (2d and 4, siblings on `
|
|||
4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): slots `Speaker` and `Scene` with their seed features; `weksa.speaker` (`speaker:<entity-slug>@<n>`) and `weksa.scene` (`scene:<group>@<n>`, participants and ordered-pair relations) documents with `Annotator` `Operator` and `Model { family, generator }`, validated by the packet's shared feature check, append-only per key; their stores, `weksa speaker` and `weksa scene` commands, published schemas; Model-annotated cast speakers and scenes for the committed items. Waits on 2d and 4; `pd-speaker-annotation` blocks only the public-domain annotator. Part 1 landed (branch `weksa-lowering/speaker-scene`): `speaker.rs`, `SpeakerStateStore`, slots `Speaker` and `Scene` exclusive to their own feature lists, `speaker_scene_revision()` (Speaker features 22-24), `with_scene` on the store, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c.
|
||||
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. Merges follow-ups `loop-context-builder-2` and `scorer-test-readings-type`. (spec r1)
|
||||
7. `golf-loop-en`: the loop driver, the template wording in `context.rs` with its digest pinned per run (follow-up `template-digest-in-round-record`), the packet store bound to one inventory store (`packet-store-inventory-binding`), `ModelPort` with the `claude -p` adapter (it takes only `&Prompt`), encoder (packet with stance) and lowerer prompts, round records, Score documents, the composite and accept rule, `weksa score`, the detached unit on Yggdrasil. Waits on 3, 5, 6 and follow-up `claude-on-yggdrasil`.
|
||||
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`, `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 `<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.
|
||||
7. `golf-loop-en` (7b landed its round, `ModelPort`, encoder prompt, template digest and round records; the rest remains): the loop driver, the packet store bound to one inventory store everywhere (`packet-store-inventory-binding`), a subprocess adapter, lowerer prompts, Score documents, the composite and accept rule, `weksa score`, 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`.
|
||||
|
|
|
|||
241
schemas/cultnet/weksa.loop_round.schema.json
Normal file
241
schemas/cultnet/weksa.loop_round.schema.json
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
{
|
||||
"$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 }\n}\n```",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"calls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Call"
|
||||
}
|
||||
},
|
||||
"chrf_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"corpus_digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"embedder": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"inventory_rev": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
},
|
||||
"lowerer_digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"n": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
},
|
||||
"prompt_digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"train": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/TrainRow"
|
||||
}
|
||||
},
|
||||
"train_mean": {
|
||||
"$ref": "#/$defs/SplitMean"
|
||||
},
|
||||
"train_scored": {
|
||||
"description": "Train items that lowered and scored; `train_mean` is over all of them\nand counts a refused item as 0.",
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
},
|
||||
"validation_mean": {
|
||||
"$ref": "#/$defs/SplitMean"
|
||||
},
|
||||
"validation_refused": {
|
||||
"$ref": "#/$defs/RefusalCounts"
|
||||
},
|
||||
"validation_scored": {
|
||||
"description": "Validation items that lowered and scored; `validation_mean` is over all\nof them and counts a refused item as 0, so refusing a weak item cannot\nraise it.",
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"n",
|
||||
"inventory_rev",
|
||||
"prompt_digest",
|
||||
"lowerer_digest",
|
||||
"corpus_digest",
|
||||
"entity_digest",
|
||||
"model",
|
||||
"embedder",
|
||||
"chrf_version",
|
||||
"calls",
|
||||
"train",
|
||||
"train_mean",
|
||||
"validation_mean",
|
||||
"validation_refused",
|
||||
"train_scored",
|
||||
"validation_scored"
|
||||
],
|
||||
"$defs": {
|
||||
"Call": {
|
||||
"description": "One model call: the prompt's digest and the item keys it held.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"prompt",
|
||||
"items"
|
||||
]
|
||||
},
|
||||
"Outcome": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Scored": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chrf": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"meaning": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"chrf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Scored"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Refused": {
|
||||
"$ref": "#/$defs/Stage"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Refused"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"RefusalCounts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"admit": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
},
|
||||
"lower": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
},
|
||||
"parse": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"parse",
|
||||
"admit",
|
||||
"lower"
|
||||
]
|
||||
},
|
||||
"SplitMean": {
|
||||
"description": "Mean scores of one derived split.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chrf": {
|
||||
"description": "`None` when the split has no items.",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"minimum": 0
|
||||
},
|
||||
"meaning": {
|
||||
"description": "Mean over the items that have a meaning score; `None` when none do.",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"count"
|
||||
]
|
||||
},
|
||||
"Stage": {
|
||||
"description": "Where a round refused: the first stage that did.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Parse",
|
||||
"Admit",
|
||||
"Lower"
|
||||
]
|
||||
},
|
||||
"TrainRow": {
|
||||
"description": "A Train item's row. Validation has no rows.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {
|
||||
"type": "string"
|
||||
},
|
||||
"outcome": {
|
||||
"$ref": "#/$defs/Outcome"
|
||||
},
|
||||
"packet": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"item",
|
||||
"outcome"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
58
schemas/cultnet/weksa.loop_run.schema.json
Normal file
58
schemas/cultnet/weksa.loop_run.schema.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "RunRecord",
|
||||
"description": "What a run pinned at open. Key `run:<label>`.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chrf_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"corpus": {
|
||||
"description": "Canonical absolute path of the corpus store.",
|
||||
"type": "string"
|
||||
},
|
||||
"corpus_digest": {
|
||||
"description": "16 hex of the corpus file's bytes.",
|
||||
"type": "string"
|
||||
},
|
||||
"embedder": {
|
||||
"description": "The embedder's model.",
|
||||
"type": "string"
|
||||
},
|
||||
"entities": {
|
||||
"type": "string"
|
||||
},
|
||||
"entity_digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"inventory": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"lowerer_digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"description": "The answering model.",
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_digest": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"corpus",
|
||||
"inventory",
|
||||
"entities",
|
||||
"corpus_digest",
|
||||
"entity_digest",
|
||||
"prompt_digest",
|
||||
"lowerer_digest",
|
||||
"model",
|
||||
"embedder",
|
||||
"chrf_version"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue