Read a mind: views, queries, open items and history
`query.rs` is the read side, over a `&Mind<S>` with no store file, no daemon and no index. A `PipelineDocumentView` is four fields: the id, because a `PipelineDocument` does not carry its key and a client must never derive one; the document; the facts of its admission; and its status. The facts come from the commit receipts and from their `writes` alone, so a document a later batch cited as a strong read keeps the receipt that wrote it, and `admitted_at` is that receipt's `committed_at` -- the `now` admission was passed -- never the store's `stored_at`. A pipeline document the image holds and no receipt wrote is an integrity fault and refuses; the store has one writer and the receipt is its proof. Status is derived per call through `docs::Docs`, the same owner the admission rules ask, so a withdrawn resolution reopens its subject for a reader exactly when it reopens it for a rule. `closing_resolution` is `in_force_unless`'s own body asked for the resolution rather than the bool, so there is no second derivation to drift. Four reads: `view` by id, `query` over the filters (root, repo, cut, kinds, in force, faculty, an admission window), `open_items` for one campaign's unanswered questions, standing findings, follow-ups, unreported specs and unjudged reports, and `history` for every resolution a subject ever had or every assignment of a repo to this mind, withdrawn ones included with their reasons. A query is ordered by `(admitted_at, id)`, capped at 200, and carries `matched`, the count before the cap, so a truncation is never a short page that reads like the whole answer. `semantic` is accepted by the type and refused typed until Cut 11 owns the index; no substring filter lives here to become a second answer to "the text of a document". `cargo test -p huginn-mind --lib`: 56 passed, 0 failed, 0 warnings (46 + the ten tests). `cargo check --workspace` clean. No dependency, no target, no kind, no schema, no epoch; `Cargo.toml` and `Cargo.lock` untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
5ae34f8147
commit
49cc6e3e62
6 changed files with 976 additions and 6 deletions
|
|
@ -14,10 +14,12 @@ Studio's job, not Huginn's.
|
|||
- Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and
|
||||
`eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh
|
||||
runtimes come from `F:\Projects\CultLib\packages`. `huginn-mind` is live:
|
||||
it persists and admits an instance's mind, over `cultcache-rs` and
|
||||
`epiphany-pipeline` pinned by git rev. `huginn-daemon` and `eureka-state`
|
||||
it persists, admits and reads back an instance's mind, queries and derived
|
||||
status included, over `cultcache-rs` and `epiphany-pipeline` pinned by git
|
||||
rev. `huginn-daemon` and `eureka-state`
|
||||
are stubs with no dependencies; nothing here publishes or connects yet.
|
||||
- Owned: an instance's memory documents and their admission, in `huginn-mind`,
|
||||
- Owned: an instance's memory documents, their admission and the read side
|
||||
that derives their status, in `huginn-mind`,
|
||||
over a redb CultCache store at `<state_root>/minds/<instance>/mind.redb`.
|
||||
Their CultNet publication is owned here and not built; `huginn-daemon` is a
|
||||
stub.
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -28,7 +28,8 @@ edits `.cc` state. Huginn reads and writes minds.
|
|||
|
||||
A Cargo workspace of three crates:
|
||||
|
||||
- `crates/huginn-mind`: storage, identity, and admission of memory documents.
|
||||
- `crates/huginn-mind`: storage, identity, admission, and queries and derived
|
||||
status over memory documents.
|
||||
- `crates/huginn-daemon`: a stub. It will carry the CultNet surface and serve
|
||||
loop; today it holds neither.
|
||||
- `crates/eureka-state`: a stub. It will carry typed state for the Eureka
|
||||
|
|
@ -43,8 +44,12 @@ keys, then the organ's cross-document rules (references, in-force status,
|
|||
the resolution matrix, derived resolutions and stewardships), then one
|
||||
compare-and-swap that lands the batch whole with a receipt naming the exact
|
||||
bytes it read and wrote. An exact replay answers with the stored receipt.
|
||||
Document shape and keys come from `epiphany-pipeline`; the store is CultLib's
|
||||
Rust CultCache.
|
||||
It reads them back the same way: a document with the facts of its admission
|
||||
joined from that receipt and its status derived at read time, typed queries
|
||||
over one mind, a campaign's open work, and a subject's or a repo's history.
|
||||
Status is never stored, and the views derive it through the same rules
|
||||
admission does. Document shape and keys come from `epiphany-pipeline`; the
|
||||
store is CultLib's Rust CultCache.
|
||||
`huginn-daemon` and `eureka-state` are stubs; the
|
||||
campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns what
|
||||
each crate must do next.
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@ impl Docs {
|
|||
})
|
||||
}
|
||||
|
||||
/// The resolution that closes the document, if any: the views' status,
|
||||
/// derived where admission derives whether there is one at all.
|
||||
pub(crate) fn closing_resolution(&self, kind: PipelineKind, id: &str) -> Option<(&str, &PipelineResolution)> {
|
||||
self.closing_resolution_unless(kind, id, |_| false)
|
||||
}
|
||||
|
||||
/// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal
|
||||
/// `hand_off_key` derives. The stewardship row holds one in force at a
|
||||
/// time, but that exclusion can put a second back in the list: the one a
|
||||
|
|
|
|||
|
|
@ -330,10 +330,37 @@ pub(crate) fn opened<S: MindStore>(store: S, instance: &str) -> Mind<S> {
|
|||
|
||||
/// Admits as Hands at the fixed clock.
|
||||
pub(crate) fn admit<S: MindStore>(mind: &mut Mind<S>, documents: Vec<PipelineDocument>) -> PipelineAdmissionOutcome {
|
||||
admit_at(mind, documents, now())
|
||||
}
|
||||
|
||||
/// Admits as Hands at the clock the caller names: the views order by the
|
||||
/// receipt's `committed_at`, which is this `now`.
|
||||
pub(crate) fn admit_at<S: MindStore>(
|
||||
mind: &mut Mind<S>,
|
||||
documents: Vec<PipelineDocument>,
|
||||
now: DateTime<Utc>,
|
||||
) -> PipelineAdmissionOutcome {
|
||||
let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(Faculty::Hands), documents };
|
||||
mind.admit(batch, now)
|
||||
}
|
||||
|
||||
/// Admits as another faculty at the fixed clock: attribution the views filter
|
||||
/// on and no rule reads (ruling 18).
|
||||
pub(crate) fn admit_as<S: MindStore>(
|
||||
mind: &mut Mind<S>,
|
||||
faculty: Faculty,
|
||||
documents: Vec<PipelineDocument>,
|
||||
) -> PipelineAdmissionOutcome {
|
||||
let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(faculty), documents };
|
||||
mind.admit(batch, now())
|
||||
}
|
||||
|
||||
/// The `n`th plain question, for the tests that need more of them than they
|
||||
/// need to tell apart.
|
||||
pub(crate) fn question_n(n: u32) -> PipelineDocument {
|
||||
question(&format!("Q{n}"), &["A", "B"], "A")
|
||||
}
|
||||
|
||||
/// The first write into a fresh yggdrasil mind: its identity, stewardship of
|
||||
/// the campaign repo, the campaign and a first target with one invariant.
|
||||
pub(crate) fn seed<S: MindStore>(mind: &mut Mind<S>) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
//! cross-document rule, every derived write, and the replay check, in one
|
||||
//! ordered path that `admit`, the daemon's sink and the hand-off all share.
|
||||
//!
|
||||
//! The read side derives status, joins the receipts and answers typed queries
|
||||
//! through the same `docs` the rules use; nothing is stored for it.
|
||||
//!
|
||||
//! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the
|
||||
//! organ registers, prepares, decodes and validates through the leaf's four
|
||||
//! doors and never re-derives a key. The store is CultLib's owned redb
|
||||
|
|
@ -20,6 +23,7 @@
|
|||
pub mod admission;
|
||||
mod docs;
|
||||
pub mod mind;
|
||||
pub mod query;
|
||||
pub mod receipt;
|
||||
pub mod refusal;
|
||||
pub mod store;
|
||||
|
|
@ -29,6 +33,10 @@ pub(crate) mod fixtures;
|
|||
|
||||
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};
|
||||
pub use mind::{HuginnMindEpoch, Mind};
|
||||
pub use query::{
|
||||
AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage,
|
||||
PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery,
|
||||
};
|
||||
pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION};
|
||||
pub use refusal::MindRefusal;
|
||||
pub use store::MindStore;
|
||||
|
|
|
|||
922
crates/huginn-mind/src/query.rs
Normal file
922
crates/huginn-mind/src/query.rs
Normal file
|
|
@ -0,0 +1,922 @@
|
|||
//! The read side of a mind: a document with the facts of its admission and
|
||||
//! its status, and the four queries over them.
|
||||
//!
|
||||
//! Nothing here is stored. Status is derived per call through the same
|
||||
//! `docs::Docs` the admission rules use, so a withdrawn resolution reopens its
|
||||
//! subject for a reader exactly when it reopens it for a rule. The facts come
|
||||
//! from the commit receipts, from their `writes` and from nothing else: a
|
||||
//! document's `admitted_at` is the `now` its batch was admitted at
|
||||
//! (`committed_at`), never the store's `stored_at` stamp. A pipeline document
|
||||
//! in the image that no receipt wrote is an integrity fault and refuses; the
|
||||
//! store has one writer and the receipt is its proof.
|
||||
//!
|
||||
//! `semantic` is accepted by the type and refused typed until Cut 11 wires the
|
||||
//! index. No clock, no store handle, no network.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use epiphany_pipeline::{
|
||||
Label, Line, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, Short, Slug,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::docs::{Docs, Held};
|
||||
use crate::mind::Mind;
|
||||
use crate::receipt::{Faculty, PipelineProvenance};
|
||||
use crate::refusal::MindRefusal;
|
||||
use crate::store::MindStore;
|
||||
|
||||
/// The most views one query answers with. A query that matched more says so in
|
||||
/// `matched`; the cap never truncates silently.
|
||||
pub const QUERY_LIMIT_MAX: usize = 200;
|
||||
|
||||
/// What admission recorded about a document when it landed: the receipt that
|
||||
/// wrote it, the `now` that receipt was taken at, and who asked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AdmissionFacts {
|
||||
pub receipt_id: String,
|
||||
pub admitted_at: String,
|
||||
pub provenance: PipelineProvenance,
|
||||
}
|
||||
|
||||
/// Whether a document still stands, and if not, what closed it. Derived at
|
||||
/// read time; no document carries a status field and no admission writes one.
|
||||
/// The whole closing record travels, not only its outcome, because a reader
|
||||
/// rehydrating wants why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum PipelineStatus {
|
||||
InForce,
|
||||
Resolved { resolution: PipelineRef, record: PipelineResolution },
|
||||
}
|
||||
|
||||
/// One document as a reader gets it: its id, the document, the facts of its
|
||||
/// admission and its status. The id travels with the document because a
|
||||
/// `PipelineDocument` does not carry its key and a client must never derive
|
||||
/// one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PipelineDocumentView {
|
||||
pub id: PipelineRef,
|
||||
pub document: PipelineDocument,
|
||||
pub admission: AdmissionFacts,
|
||||
pub status: PipelineStatus,
|
||||
}
|
||||
|
||||
/// A semantic query, accepted by the type and refused until Cut 11 owns the
|
||||
/// index. Cut 11 also owns which fields of each kind are text, so no substring
|
||||
/// filter lives here to become a second answer to that question.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SemanticQuery {
|
||||
pub text: Line,
|
||||
pub top_k: u32,
|
||||
}
|
||||
|
||||
/// The filters over one mind. Each is one rule, and an unset filter matches
|
||||
/// everything. The mind is the instance, so there is no `instance` field: the
|
||||
/// daemon picks the mind before it calls here.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PipelineQuery {
|
||||
/// The key's root segment: a campaign slug, or an instance for
|
||||
/// stewardships, hand-offs and the identity document.
|
||||
pub campaign: Option<Slug>,
|
||||
pub repo: Option<OrgRepo>,
|
||||
pub cut: Option<Label>,
|
||||
/// Empty matches every kind.
|
||||
pub kinds: Vec<PipelineKind>,
|
||||
pub in_force: Option<bool>,
|
||||
/// Attribution, ruling 18: it filters, it grants nothing.
|
||||
pub faculty: Option<Faculty>,
|
||||
/// Exclusive, compared as a string against `admitted_at`.
|
||||
pub admitted_after: Option<Short>,
|
||||
/// Exclusive, compared as a string against `admitted_at`.
|
||||
pub admitted_before: Option<Short>,
|
||||
/// `None` is `QUERY_LIMIT_MAX`; anything else is clamped into
|
||||
/// `1..=QUERY_LIMIT_MAX`.
|
||||
pub limit: Option<u32>,
|
||||
pub semantic: Option<SemanticQuery>,
|
||||
}
|
||||
|
||||
/// One page of a query, with the number of documents that matched before the
|
||||
/// cap, so a short page is never mistaken for the whole answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PipelineQueryPage {
|
||||
pub items: Vec<PipelineDocumentView>,
|
||||
pub matched: u32,
|
||||
}
|
||||
|
||||
/// One campaign's open work, derived from what is in force and what cites
|
||||
/// what. Uncapped: the open set of a campaign is bounded by the campaign, and
|
||||
/// truncating it would hide work.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PipelineOpenItems {
|
||||
pub questions: Vec<PipelineDocumentView>,
|
||||
pub findings: Vec<PipelineDocumentView>,
|
||||
pub follow_ups: Vec<PipelineDocumentView>,
|
||||
pub specs_without_report: Vec<PipelineDocumentView>,
|
||||
pub reports_without_verdict: Vec<PipelineDocumentView>,
|
||||
}
|
||||
|
||||
/// What a history is a history of: every resolution one subject ever had, or
|
||||
/// every assignment of one repo to this mind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum HistoryScope {
|
||||
Subject(PipelineRef),
|
||||
Repo(OrgRepo),
|
||||
}
|
||||
|
||||
/// The admission facts of every stored document, built once per read call from
|
||||
/// the receipts. `writes` only: a document a later batch cited as a strong read
|
||||
/// keeps the receipt that wrote it. A10 admits one write per identity, so two
|
||||
/// receipts naming one document is an integrity fault, not a choice to make.
|
||||
pub(crate) struct AdmissionIndex(BTreeMap<(String, String), AdmissionFacts>);
|
||||
|
||||
impl AdmissionIndex {
|
||||
pub(crate) fn build<S: MindStore>(mind: &Mind<S>) -> Result<Self, MindRefusal> {
|
||||
let mut facts = BTreeMap::new();
|
||||
for receipt in mind.receipts()? {
|
||||
for write in receipt.writes.iter() {
|
||||
let identity = (write.document_type.clone(), write.document_key.clone());
|
||||
let landed = AdmissionFacts {
|
||||
receipt_id: receipt.receipt_id.clone(),
|
||||
admitted_at: receipt.committed_at.clone(),
|
||||
provenance: receipt.provenance.clone(),
|
||||
};
|
||||
if facts.insert(identity, landed).is_some() {
|
||||
return Err(integrity(&write.document_type, &write.document_key, "is written by two receipts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self(facts))
|
||||
}
|
||||
|
||||
fn of(&self, held: &Held) -> Result<AdmissionFacts, MindRefusal> {
|
||||
let type_id = held.kind.type_id();
|
||||
self.0
|
||||
.get(&(type_id.to_string(), held.key.clone()))
|
||||
.cloned()
|
||||
.ok_or_else(|| integrity(type_id, &held.key, "has no commit receipt"))
|
||||
}
|
||||
}
|
||||
|
||||
fn integrity(type_id: &str, key: &str, fault: &str) -> MindRefusal {
|
||||
MindRefusal::Unavailable { detail: format!("document {type_id}/{key} {fault}") }
|
||||
}
|
||||
|
||||
/// The root and the local of a key, by position in `<root>:<kind>:<local>`:
|
||||
/// the leaf derives every key that way and its own test pins the three
|
||||
/// segments. The only place the read side reads a key apart, and it reads no
|
||||
/// sequence, no label and no kind out of one.
|
||||
fn root_and_local(key: &str) -> (&str, &str) {
|
||||
let mut segments = key.split(':');
|
||||
let root = segments.next().unwrap_or_default();
|
||||
let local = segments.nth(1).unwrap_or_default();
|
||||
(root, local)
|
||||
}
|
||||
|
||||
/// Which documents carry a repo, and where: a campaign lists them, five kinds
|
||||
/// name one, and the rest never match.
|
||||
fn repo_matches(document: &PipelineDocument, repo: &OrgRepo) -> bool {
|
||||
use PipelineDocument as D;
|
||||
match document {
|
||||
D::Campaign(campaign) => campaign.repos.contains(repo),
|
||||
D::CutSpec(spec) => spec.repo == *repo,
|
||||
D::CutReport(report) => report.repo == *repo,
|
||||
D::FollowUp(follow_up) => follow_up.repo == *repo,
|
||||
D::Stewardship(stewardship) => stewardship.repo == *repo,
|
||||
D::HandOff(hand_off) => hand_off.repo == *repo,
|
||||
D::Target(_)
|
||||
| D::Question(_)
|
||||
| D::Ruling(_)
|
||||
| D::Verdict(_)
|
||||
| D::Finding(_)
|
||||
| D::Instance(_)
|
||||
| D::Resolution(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One read call's working set: the decoded image, the facts, and the mind the
|
||||
/// two came from. Built once per call and thrown away with it; nothing here
|
||||
/// outlives the answer.
|
||||
struct Reader<'a, S: MindStore> {
|
||||
docs: Docs,
|
||||
facts: AdmissionIndex,
|
||||
mind: &'a Mind<S>,
|
||||
}
|
||||
|
||||
impl<'a, S: MindStore> Reader<'a, S> {
|
||||
fn new(mind: &'a Mind<S>) -> Result<Self, MindRefusal> {
|
||||
Ok(Self { docs: Docs::from_image(mind.envelopes())?, facts: AdmissionIndex::build(mind)?, mind })
|
||||
}
|
||||
|
||||
fn held(&self, kind: PipelineKind, key: &str) -> Option<&Held> {
|
||||
self.docs.image.iter().find(|held| held.kind == kind && held.key == key)
|
||||
}
|
||||
|
||||
fn view_of(&self, held: &Held) -> Result<PipelineDocumentView, MindRefusal> {
|
||||
let admission = self.facts.of(held)?;
|
||||
let status = match self.docs.closing_resolution(held.kind, &held.key) {
|
||||
Some((key, record)) => PipelineStatus::Resolved {
|
||||
resolution: PipelineRef { kind: PipelineKind::Resolution, id: Short(key.to_string()) },
|
||||
record: record.clone(),
|
||||
},
|
||||
None => PipelineStatus::InForce,
|
||||
};
|
||||
Ok(PipelineDocumentView {
|
||||
id: PipelineRef { kind: held.kind, id: Short(held.key.clone()) },
|
||||
document: held.document.clone(),
|
||||
admission,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
/// The document a filter reads a resolution through: its subject, and its
|
||||
/// subject's subject when that is a resolution too (the Q19 cap stops the
|
||||
/// chain at two). A7 puts every subject in the image, and a resolution key
|
||||
/// is strictly longer than the id it resolves, so this walks down and
|
||||
/// stops. Anything that is not a resolution is its own base.
|
||||
fn base<'b>(&'b self, held: &'b Held) -> &'b Held {
|
||||
let mut current = held;
|
||||
while let PipelineDocument::Resolution(resolution) = ¤t.document {
|
||||
let Some(subject) = self.held(resolution.subject.kind, &resolution.subject.id.0) else {
|
||||
return current;
|
||||
};
|
||||
current = subject;
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
/// Every filter, each one rule, in one pass over one document.
|
||||
fn matches(&self, query: &PipelineQuery, held: &Held, view: &PipelineDocumentView) -> bool {
|
||||
if let Some(campaign) = &query.campaign
|
||||
&& root_and_local(&held.key).0 != campaign.0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let base = self.base(held);
|
||||
if let Some(repo) = &query.repo
|
||||
&& !repo_matches(&base.document, repo)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(cut) = &query.cut
|
||||
&& !root_and_local(&base.key).1.starts_with(&format!("cut-{}.", cut.0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !query.kinds.is_empty() && !query.kinds.contains(&held.kind) {
|
||||
return false;
|
||||
}
|
||||
if let Some(in_force) = query.in_force
|
||||
&& in_force != (view.status == PipelineStatus::InForce)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(faculty) = query.faculty
|
||||
&& view.admission.provenance.faculty != faculty
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(after) = &query.admitted_after
|
||||
&& view.admission.admitted_at <= after.0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(before) = &query.admitted_before
|
||||
&& view.admission.admitted_at >= before.0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Every view of the image, in order, refusing rather than skipping a
|
||||
/// document the receipts do not account for.
|
||||
fn views(&self) -> Result<Vec<PipelineDocumentView>, MindRefusal> {
|
||||
let mut views = Vec::with_capacity(self.docs.image.len());
|
||||
for held in &self.docs.image {
|
||||
views.push(self.view_of(held)?);
|
||||
}
|
||||
ordered(&mut views);
|
||||
Ok(views)
|
||||
}
|
||||
}
|
||||
|
||||
/// `(admitted_at, id)` ascending. The batch decides the order and the key
|
||||
/// breaks a tie inside one, which is root-first and so is not the image's
|
||||
/// `(type, key)` order.
|
||||
fn ordered(views: &mut [PipelineDocumentView]) {
|
||||
views.sort_by(|left, right| {
|
||||
(&left.admission.admitted_at, &left.id.id.0).cmp(&(&right.admission.admitted_at, &right.id.id.0))
|
||||
});
|
||||
}
|
||||
|
||||
impl<S: MindStore> Mind<S> {
|
||||
/// One document with its admission facts and its derived status, or
|
||||
/// `None` when the mind does not hold it.
|
||||
pub fn view(&self, id: &PipelineRef) -> Result<Option<PipelineDocumentView>, MindRefusal> {
|
||||
let reader = Reader::new(self)?;
|
||||
reader.held(id.kind, &id.id.0).map(|held| reader.view_of(held)).transpose()
|
||||
}
|
||||
|
||||
/// The documents matching every set filter, ordered and capped, with the
|
||||
/// count that matched before the cap.
|
||||
pub fn query(&self, query: &PipelineQuery) -> Result<PipelineQueryPage, MindRefusal> {
|
||||
if query.semantic.is_some() {
|
||||
return Err(MindRefusal::Unavailable {
|
||||
detail: "semantic query: the index is not wired (Cut 11)".into(),
|
||||
});
|
||||
}
|
||||
let reader = Reader::new(self)?;
|
||||
let mut items = Vec::new();
|
||||
for held in &reader.docs.image {
|
||||
let view = reader.view_of(held)?;
|
||||
if reader.matches(query, held, &view) {
|
||||
items.push(view);
|
||||
}
|
||||
}
|
||||
let matched = items.len() as u32;
|
||||
ordered(&mut items);
|
||||
let limit = query.limit.map_or(QUERY_LIMIT_MAX, |limit| (limit as usize).clamp(1, QUERY_LIMIT_MAX));
|
||||
items.truncate(limit);
|
||||
Ok(PipelineQueryPage { items, matched })
|
||||
}
|
||||
|
||||
/// One campaign's open work: the questions, findings and follow-ups still
|
||||
/// in force, the in-force cut specs no report names, and the cut reports no
|
||||
/// verdict names.
|
||||
pub fn open_items(&self, campaign: &Slug) -> Result<PipelineOpenItems, MindRefusal> {
|
||||
let reader = Reader::new(self)?;
|
||||
let views = reader
|
||||
.views()?
|
||||
.into_iter()
|
||||
.filter(|view| root_and_local(&view.id.id.0).0 == campaign.0)
|
||||
.collect::<Vec<_>>();
|
||||
let of_kind = |kind: PipelineKind| views.iter().filter(move |view| view.id.kind == kind);
|
||||
let in_force = |kind: PipelineKind| {
|
||||
of_kind(kind).filter(|view| view.status == PipelineStatus::InForce).cloned().collect::<Vec<_>>()
|
||||
};
|
||||
let cited = |referent: fn(&PipelineDocument) -> Option<&str>| {
|
||||
views.iter().filter_map(|view| referent(&view.document)).collect::<Vec<_>>()
|
||||
};
|
||||
let reported = cited(|document| match document {
|
||||
PipelineDocument::CutReport(report) => Some(report.cut_spec.0.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
let judged = cited(|document| match document {
|
||||
PipelineDocument::Verdict(verdict) => Some(verdict.cut_report.0.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
Ok(PipelineOpenItems {
|
||||
questions: in_force(PipelineKind::Question),
|
||||
findings: in_force(PipelineKind::Finding),
|
||||
follow_ups: in_force(PipelineKind::FollowUp),
|
||||
specs_without_report: in_force(PipelineKind::CutSpec)
|
||||
.into_iter()
|
||||
.filter(|view| !reported.contains(&view.id.id.0.as_str()))
|
||||
.collect(),
|
||||
// A report is not resolvable, so standing is not a condition here.
|
||||
reports_without_verdict: of_kind(PipelineKind::CutReport)
|
||||
.filter(|view| !judged.contains(&view.id.id.0.as_str()))
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every record of one scope, withdrawn ones included with their status
|
||||
/// and their reasons, in the scope's own sequence order. Uncapped: a
|
||||
/// scope's history is bounded by the scope.
|
||||
pub fn history(&self, scope: &HistoryScope) -> Result<Vec<PipelineDocumentView>, MindRefusal> {
|
||||
let reader = Reader::new(self)?;
|
||||
let (kind, mut records) = match scope {
|
||||
HistoryScope::Subject(subject) => (
|
||||
PipelineKind::Resolution,
|
||||
reader
|
||||
.docs
|
||||
.resolutions_of(subject)
|
||||
.map(|(key, resolution)| (resolution.sequence, key.to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
HistoryScope::Repo(repo) => (
|
||||
PipelineKind::Stewardship,
|
||||
reader
|
||||
.docs
|
||||
.assignments_of(reader.mind.instance(), repo)
|
||||
.map(|(key, stewardship)| (stewardship.sequence, key.to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
};
|
||||
// The sequence owns history order. `admitted_at` coincides in a mind
|
||||
// written in order, but it is not the rule.
|
||||
records.sort_by_key(|(sequence, _)| *sequence);
|
||||
let mut views = Vec::with_capacity(records.len());
|
||||
for (_, key) in records {
|
||||
let held = reader
|
||||
.held(kind, &key)
|
||||
.ok_or_else(|| integrity(kind.type_id(), &key, "is derived over the image but not held in it"))?;
|
||||
views.push(reader.view_of(held)?);
|
||||
}
|
||||
Ok(views)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::*;
|
||||
use crate::fixtures::*;
|
||||
use crate::store::test_stores::MemoryStore;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use epiphany_pipeline::{
|
||||
ClaimOutcome, Date, FindingConfidence, PipelineDocument as D, PipelineKind as K, ResolutionOutcome,
|
||||
};
|
||||
|
||||
fn view(mind: &Mind<MemoryStore>, kind: K, key: &str) -> PipelineDocumentView {
|
||||
mind.view(&r(kind, key)).unwrap().unwrap_or_else(|| panic!("{key} is not held"))
|
||||
}
|
||||
|
||||
fn ids(views: &[PipelineDocumentView]) -> Vec<String> {
|
||||
views.iter().map(|view| view.id.id.0.clone()).collect()
|
||||
}
|
||||
|
||||
/// A ruling that answers a question, which is what derives its
|
||||
/// `Answered` resolution.
|
||||
fn answering(label: &str, question: &str) -> D {
|
||||
let mut ruling = ruling(label);
|
||||
ruling.answers = Some(s(question));
|
||||
ruling.choice = Some(l("A"));
|
||||
D::Ruling(ruling)
|
||||
}
|
||||
|
||||
/// A question of another campaign, beside that campaign's own record, so
|
||||
/// no filter can pass by matching everything.
|
||||
fn elsewhere(label: &str) -> Vec<D> {
|
||||
let D::Campaign(mut campaign) = campaign(&[REPO]) else { panic!() };
|
||||
campaign.slug = slug("other-campaign");
|
||||
let mut question = question(label, &["A", "B"], "A");
|
||||
let D::Question(value) = &mut question else { panic!() };
|
||||
value.campaign = slug("other-campaign");
|
||||
vec![D::Campaign(campaign), question]
|
||||
}
|
||||
|
||||
/// R-A: the status a reader sees is the recursive derivation admission
|
||||
/// rules with, reopen case included. A test pinning the non-recursive
|
||||
/// form would pin the defect Cut 6d closed.
|
||||
#[test]
|
||||
fn status_is_the_derivation_admission_uses() {
|
||||
let mut mind = seeded();
|
||||
let q1 = id("question", "Q1");
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
|
||||
assert_eq!(view(&mind, K::Question, &q1).status, PipelineStatus::InForce);
|
||||
|
||||
// The ruling answers it; what closes it is the resolution admission
|
||||
// derived, and the view names that resolution and carries it whole.
|
||||
let closed = id("resolution", "question.Q1.n1");
|
||||
committed(admit(&mut mind, vec![answering("R1", &q1)]));
|
||||
let PipelineStatus::Resolved { resolution: closing, record } = view(&mind, K::Question, &q1).status else {
|
||||
panic!("the answered question is resolved")
|
||||
};
|
||||
assert_eq!(closing, r(K::Resolution, &closed));
|
||||
assert!(matches!(&record.outcome, ResolutionOutcome::Answered { by } if by.id.0 == id("ruling", "R1")));
|
||||
|
||||
// Withdrawing that resolution reopens the question, and the withdrawn
|
||||
// resolution is a document with a status of its own: this is how
|
||||
// R-B's "with their reasons" reaches a reader.
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Resolution, &closed), withdrawn())]));
|
||||
assert_eq!(view(&mind, K::Question, &q1).status, PipelineStatus::InForce);
|
||||
let PipelineStatus::Resolved { resolution: closing, record } = view(&mind, K::Resolution, &closed).status else {
|
||||
panic!("the withdrawn resolution is resolved by its withdrawal")
|
||||
};
|
||||
assert_eq!(closing, r(K::Resolution, &id("resolution", "resolution.question.Q1.n1.n1")));
|
||||
assert!(matches!(&record.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == "moot"));
|
||||
|
||||
// The next ruling closes it again, at the next sequence.
|
||||
committed(admit(&mut mind, vec![answering("R2", &q1)]));
|
||||
let PipelineStatus::Resolved { resolution: closing, .. } = view(&mind, K::Question, &q1).status else { panic!() };
|
||||
assert_eq!(closing, r(K::Resolution, &id("resolution", "question.Q1.n2")));
|
||||
|
||||
// A ruling is not resolved by the withdrawal of the resolution it
|
||||
// derived, and the kinds the matrix makes unresolvable are in force by
|
||||
// the same derivation, with no special case for them.
|
||||
assert_eq!(view(&mind, K::Ruling, &id("ruling", "R1")).status, PipelineStatus::InForce);
|
||||
assert_eq!(view(&mind, K::Campaign, &id("campaign", "self")).status, PipelineStatus::InForce);
|
||||
committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))]));
|
||||
committed(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))]));
|
||||
committed(admit(&mut mind, vec![
|
||||
verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&id("finding", "cut-1.s1.F1")], Some("P1"), &["M1"])]),
|
||||
D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)),
|
||||
]));
|
||||
assert_eq!(view(&mind, K::Verdict, &id("verdict", "cut-1.s1")).status, PipelineStatus::InForce);
|
||||
}
|
||||
|
||||
/// R-B and D7: a subject's resolutions are a first-class view, withdrawn
|
||||
/// ones included with their status and their reasons, in sequence order.
|
||||
#[test]
|
||||
fn a_subjects_history_lists_every_resolution_with_its_status_and_receipt() {
|
||||
let mut mind = seeded();
|
||||
let q1 = id("question", "Q1");
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
|
||||
// Ten reopen cycles: each ruling answers, each withdrawal but the last
|
||||
// reopens.
|
||||
for cycle in 1..=10u32 {
|
||||
committed(admit(&mut mind, vec![answering(&format!("R{cycle}"), &q1)]));
|
||||
if cycle < 10 {
|
||||
let closed = id("resolution", &format!("question.Q1.n{cycle}"));
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Resolution, &closed), withdrawn())]));
|
||||
}
|
||||
}
|
||||
|
||||
let history = mind.history(&HistoryScope::Subject(r(K::Question, &q1))).unwrap();
|
||||
assert_eq!(
|
||||
ids(&history),
|
||||
(1..=10).map(|n| id("resolution", &format!("question.Q1.n{n}"))).collect::<Vec<_>>(),
|
||||
"sequence order, so n10 lands after n9 and not after n1 as the key string would have it"
|
||||
);
|
||||
for (index, entry) in history.iter().enumerate() {
|
||||
let sequence = index as u32 + 1;
|
||||
if sequence == 10 {
|
||||
assert_eq!(entry.status, PipelineStatus::InForce, "the standing closure");
|
||||
continue;
|
||||
}
|
||||
let PipelineStatus::Resolved { resolution, record } = &entry.status else { panic!("n{sequence}") };
|
||||
let withdrawal = id("resolution", &format!("resolution.question.Q1.n{sequence}.n1"));
|
||||
assert_eq!(*resolution, r(K::Resolution, &withdrawal));
|
||||
assert!(matches!(&record.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == "moot"));
|
||||
}
|
||||
|
||||
// A withdrawal's subject is the resolution, not the question, so it is
|
||||
// in the resolution's history and not the question's.
|
||||
let n1 = id("resolution", "question.Q1.n1");
|
||||
assert_eq!(ids(&mind.history(&HistoryScope::Subject(r(K::Resolution, &n1))).unwrap()), vec![
|
||||
id("resolution", "resolution.question.Q1.n1.n1")
|
||||
]);
|
||||
|
||||
// History lands one record per batch (Cut 12's import constraint), so
|
||||
// ten records carry ten receipts.
|
||||
let receipts = history.iter().map(|entry| entry.admission.receipt_id.clone()).collect::<BTreeSet<_>>();
|
||||
assert_eq!(receipts.len(), 10);
|
||||
}
|
||||
|
||||
/// D7's other scope: every assignment of one repo to this mind, the
|
||||
/// withdrawn ones with the hand-off that withdrew them.
|
||||
#[test]
|
||||
fn a_repos_stewardship_history_on_a_mind_lists_every_assignment() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![stewardship(INSTANCE, OTHER_REPO)]));
|
||||
let away = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.2026-09-16");
|
||||
committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])]));
|
||||
let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() };
|
||||
back.handed_on = Date("2026-09-17".into());
|
||||
committed(admit(&mut mind, vec![D::HandOff(back)]));
|
||||
|
||||
let history = mind.history(&HistoryScope::Repo(repo(REPO))).unwrap();
|
||||
assert_eq!(ids(&history), vec![
|
||||
format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"),
|
||||
format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n2"),
|
||||
]);
|
||||
let PipelineStatus::Resolved { record, .. } = &history[0].status else { panic!("handed away") };
|
||||
assert!(
|
||||
matches!(&record.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == away),
|
||||
"the withdrawal names the hand-off that caused it"
|
||||
);
|
||||
assert_eq!(history[1].status, PipelineStatus::InForce);
|
||||
let (D::Stewardship(first), D::Stewardship(second)) = (&history[0].document, &history[1].document) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(first.assigned_on, date());
|
||||
assert_eq!(second.assigned_on, Date("2026-09-17".into()), "the assignment carries the hand-off's day");
|
||||
|
||||
// Another repo's assignments are another history; the scope is
|
||||
// `(this mind, this repo)` and nothing else.
|
||||
assert_eq!(ids(&mind.history(&HistoryScope::Repo(repo(OTHER_REPO))).unwrap()), vec![
|
||||
format!("{INSTANCE}:stewardship:GameCult_-Huginn.n1")
|
||||
]);
|
||||
}
|
||||
|
||||
/// D3: the facts come from the one receipt that wrote the document, from
|
||||
/// `writes` alone, and a row no receipt wrote refuses rather than showing.
|
||||
#[test]
|
||||
fn views_join_admission_facts_from_the_receipt_that_wrote_them() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = opened(store.clone(), INSTANCE);
|
||||
seed(&mut mind);
|
||||
let q1 = id("question", "Q1");
|
||||
let asked = Utc.with_ymd_and_hms(2026, 9, 16, 9, 0, 0).unwrap();
|
||||
let (question_receipt, _) = committed(admit_at(&mut mind, vec![question("Q1", &["A", "B"], "A")], asked));
|
||||
let facts = view(&mind, K::Question, &q1).admission;
|
||||
assert_eq!(facts.receipt_id, question_receipt);
|
||||
assert_eq!(facts.admitted_at, "2026-09-16T09:00:00Z", "the `now` admission was passed");
|
||||
assert_eq!(facts.provenance, provenance(Faculty::Hands));
|
||||
|
||||
// The ruling's batch writes the ruling and derives the resolution, so
|
||||
// both carry its receipt. The question it cites is a strong read, and a
|
||||
// strong read is not a write: it keeps the receipt that wrote it.
|
||||
let ruled = Utc.with_ymd_and_hms(2026, 9, 16, 10, 0, 0).unwrap();
|
||||
let (ruling_receipt, _) = committed(admit_at(&mut mind, vec![answering("R1", &q1)], ruled));
|
||||
assert_eq!(view(&mind, K::Ruling, &id("ruling", "R1")).admission.receipt_id, ruling_receipt);
|
||||
let derived = view(&mind, K::Resolution, &id("resolution", "question.Q1.n1")).admission;
|
||||
assert_eq!(derived.receipt_id, ruling_receipt, "a derived write carries the batch's receipt");
|
||||
assert_eq!(derived.admitted_at, "2026-09-16T10:00:00Z");
|
||||
assert_eq!(view(&mind, K::Question, &q1).admission.receipt_id, question_receipt, "the cited document keeps its first");
|
||||
|
||||
// A pipeline document the image holds and no receipt names is an
|
||||
// integrity fault: the store has one writer and the receipt is its
|
||||
// proof, so it refuses from `view` and `query` alike, never a skip.
|
||||
let planted = MemoryStore::new();
|
||||
for row in store.rows() {
|
||||
planted.plant(row);
|
||||
}
|
||||
planted.plant(prepare(&question("Q9", &["A", "B"], "A")));
|
||||
let orphaned = opened(planted, INSTANCE);
|
||||
let orphan = id("question", "Q9");
|
||||
let detail = format!("document {}/{orphan} has no commit receipt", K::Question.type_id());
|
||||
assert_eq!(
|
||||
orphaned.view(&r(K::Question, &orphan)).err(),
|
||||
Some(MindRefusal::Unavailable { detail: detail.clone() })
|
||||
);
|
||||
assert_eq!(orphaned.query(&PipelineQuery::default()).err(), Some(MindRefusal::Unavailable { detail }));
|
||||
}
|
||||
|
||||
/// R-C: `stored_at` is the store's stamp and no derivation reads it.
|
||||
/// Scramble every stamp and the answers do not move.
|
||||
#[test]
|
||||
fn views_read_admitted_at_from_the_receipt_not_stored_at() {
|
||||
let store = MemoryStore::new();
|
||||
let mut mind = opened(store.clone(), INSTANCE);
|
||||
seed(&mut mind);
|
||||
let q1 = id("question", "Q1");
|
||||
committed(admit_at(
|
||||
&mut mind,
|
||||
vec![question("Q1", &["A", "B"], "A")],
|
||||
Utc.with_ymd_and_hms(2026, 9, 16, 9, 0, 0).unwrap(),
|
||||
));
|
||||
committed(admit_at(&mut mind, vec![answering("R1", &q1)], Utc.with_ymd_and_hms(2026, 9, 17, 9, 0, 0).unwrap()));
|
||||
|
||||
let rows = store.rows();
|
||||
let reopened = |stamps: Vec<String>| {
|
||||
let copy = MemoryStore::new();
|
||||
for (row, stored_at) in rows.iter().zip(stamps) {
|
||||
let mut row = row.clone();
|
||||
row.stored_at = stored_at;
|
||||
copy.plant(row);
|
||||
}
|
||||
opened(copy, INSTANCE)
|
||||
};
|
||||
let scope = HistoryScope::Subject(r(K::Question, &q1));
|
||||
let expected = (mind.query(&PipelineQuery::default()).unwrap(), mind.history(&scope).unwrap());
|
||||
let constant = vec!["2000-01-01T00:00:00Z".to_string(); rows.len()];
|
||||
let reversed = rows.iter().rev().map(|row| row.stored_at.clone()).collect::<Vec<_>>();
|
||||
for other in [reopened(constant), reopened(reversed)] {
|
||||
assert_eq!(other.query(&PipelineQuery::default()).unwrap(), expected.0);
|
||||
assert_eq!(other.history(&scope).unwrap(), expected.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// D5: one assertion per filter, each selecting by its own field alone.
|
||||
#[test]
|
||||
fn query_filters_each_select_by_one_field() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, elsewhere("Q1")));
|
||||
committed(admit(&mut mind, vec![
|
||||
question("Q1", &["A", "B"], "A"),
|
||||
D::Ruling(ruling("R1")),
|
||||
D::CutSpec(cut_spec("9", 1)),
|
||||
D::CutSpec(cut_spec("10", 1)),
|
||||
follow_up("FU-1", r(K::Question, &id("question", "Q1"))),
|
||||
]));
|
||||
committed(admit(&mut mind, vec![D::CutReport(cut_report("9", 1)), D::CutReport(cut_report("10", 1))]));
|
||||
committed(admit(&mut mind, vec![
|
||||
verdict("9", 1, vec![claim(ClaimOutcome::Falsified, &[&id("finding", "cut-9.s1.F1")], Some("P1"), &["M1"])]),
|
||||
D::Finding(finding("9", 1, "F1", FindingConfidence::Confirmed)),
|
||||
]));
|
||||
committed(admit(&mut mind, vec![
|
||||
D::CutSpec(cut_spec("9", 2)),
|
||||
resolution(r(K::CutSpec, &id("cut_spec", "cut-9.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-9.r2"))])),
|
||||
]));
|
||||
committed(admit_as(&mut mind, Faculty::Soul, vec![D::Ruling(ruling("R2"))]));
|
||||
// One batch at a later clock, so the window filters have a boundary.
|
||||
let later = Utc.with_ymd_and_hms(2026, 9, 17, 12, 0, 0).unwrap();
|
||||
committed(admit_at(&mut mind, vec![question_n(2)], later));
|
||||
|
||||
// Sorted, because the order is the next test's rule and not this one's.
|
||||
let matching = |query: PipelineQuery| {
|
||||
let mut matched = ids(&mind.query(&query).unwrap().items);
|
||||
matched.sort();
|
||||
matched
|
||||
};
|
||||
let supersession = id("resolution", "cut_spec.cut-9.r1.n1");
|
||||
|
||||
// `campaign`: the key's root, which is a campaign slug for most kinds
|
||||
// and an instance for the ones that hang off one.
|
||||
let eureka = matching(PipelineQuery { campaign: Some(slug(CAMPAIGN)), ..Default::default() });
|
||||
assert!(eureka.contains(&id("question", "Q1")));
|
||||
assert!(eureka.contains(&supersession), "a resolution is keyed inside its subject's root");
|
||||
assert!(!eureka.contains(&"other-campaign:question:Q1".to_string()));
|
||||
assert_eq!(matching(PipelineQuery { campaign: Some(slug(INSTANCE)), ..Default::default() }), vec![
|
||||
format!("{INSTANCE}:instance:self"),
|
||||
format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"),
|
||||
]);
|
||||
|
||||
// `repo`: a campaign lists its repos, five kinds name one, and a
|
||||
// resolution matches when its base does.
|
||||
let epiphany = matching(PipelineQuery { repo: Some(repo(REPO)), ..Default::default() });
|
||||
for expected in [
|
||||
id("campaign", "self"),
|
||||
"other-campaign:campaign:self".to_string(),
|
||||
id("cut_spec", "cut-9.r1"),
|
||||
id("cut_report", "cut-9.h1"),
|
||||
id("follow_up", "FU-1"),
|
||||
format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"),
|
||||
supersession.clone(),
|
||||
] {
|
||||
assert!(epiphany.contains(&expected), "{expected}");
|
||||
}
|
||||
assert!(!epiphany.contains(&id("question", "Q1")), "a question carries no repo");
|
||||
|
||||
// `cut`: the local's `cut-<label>.` prefix, read through the base.
|
||||
assert_eq!(matching(PipelineQuery { cut: Some(l("9")), ..Default::default() }), vec![
|
||||
id("cut_report", "cut-9.h1"),
|
||||
id("cut_spec", "cut-9.r1"),
|
||||
id("cut_spec", "cut-9.r2"),
|
||||
id("finding", "cut-9.s1.F1"),
|
||||
supersession.clone(),
|
||||
id("verdict", "cut-9.s1"),
|
||||
]);
|
||||
assert_eq!(matching(PipelineQuery { cut: Some(l("10")), ..Default::default() }), vec![
|
||||
id("cut_report", "cut-10.h1"),
|
||||
id("cut_spec", "cut-10.r1"),
|
||||
]);
|
||||
|
||||
// `kinds`, empty for every kind.
|
||||
assert_eq!(matching(PipelineQuery { kinds: vec![K::Question], ..Default::default() }), vec![
|
||||
id("question", "Q1"),
|
||||
id("question", "Q2"),
|
||||
"other-campaign:question:Q1".to_string(),
|
||||
]);
|
||||
|
||||
// `in_force`, both ways: the superseded revision is the one thing this
|
||||
// mind has closed.
|
||||
assert_eq!(matching(PipelineQuery { in_force: Some(false), ..Default::default() }), vec![id("cut_spec", "cut-9.r1")]);
|
||||
let standing = matching(PipelineQuery { in_force: Some(true), ..Default::default() });
|
||||
assert!(!standing.contains(&id("cut_spec", "cut-9.r1")));
|
||||
assert!(standing.contains(&id("cut_spec", "cut-9.r2")));
|
||||
|
||||
// `faculty`: attribution filters, and grants nothing (ruling 18).
|
||||
assert_eq!(matching(PipelineQuery { faculty: Some(Faculty::Soul), ..Default::default() }), vec![id("ruling", "R2")]);
|
||||
assert!(!matching(PipelineQuery { faculty: Some(Faculty::Hands), ..Default::default() }).contains(&id("ruling", "R2")));
|
||||
|
||||
// The window, exclusive at the exact boundary on both sides.
|
||||
let boundary = s("2026-09-17T12:00:00Z");
|
||||
let opened_at = s("2026-09-16T12:00:00Z");
|
||||
assert_eq!(matching(PipelineQuery { admitted_after: Some(opened_at.clone()), ..Default::default() }), vec![
|
||||
id("question", "Q2")
|
||||
]);
|
||||
assert!(matching(PipelineQuery { admitted_after: Some(boundary.clone()), ..Default::default() }).is_empty());
|
||||
assert!(matching(PipelineQuery { admitted_before: Some(opened_at), ..Default::default() }).is_empty());
|
||||
assert!(!matching(PipelineQuery { admitted_before: Some(boundary), ..Default::default() }).contains(&id("question", "Q2")));
|
||||
}
|
||||
|
||||
/// D5's order and cap: `(admitted_at, id)` ascending, at most 200, and the
|
||||
/// count that matched before the cap so a short page is visible as one.
|
||||
#[test]
|
||||
fn query_orders_by_admitted_at_then_id_and_caps_at_200_with_the_match_count() {
|
||||
// The seed is one batch at one `now`, so it falls to id order, which
|
||||
// is root-first and so is not the image's `(type, key)` order.
|
||||
let seeded = seeded();
|
||||
assert_eq!(ids(&seeded.query(&PipelineQuery::default()).unwrap().items), vec![
|
||||
id("campaign", "self"),
|
||||
id("target", "r1"),
|
||||
format!("{INSTANCE}:instance:self"),
|
||||
format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"),
|
||||
]);
|
||||
|
||||
// 201 questions over four batches at rising `now`s, with Q1 -- the id
|
||||
// that sorts first -- in the last batch.
|
||||
let mut mind = crate::fixtures::seeded();
|
||||
let batches: [Vec<u32>; 4] = [
|
||||
(2..=65).collect(),
|
||||
(66..=129).collect(),
|
||||
(130..=193).collect(),
|
||||
std::iter::once(1).chain(194..=201).collect(),
|
||||
];
|
||||
for (index, labels) in batches.iter().enumerate() {
|
||||
let at = Utc.with_ymd_and_hms(2026, 9, 17, 12 + index as u32, 0, 0).unwrap();
|
||||
committed(admit_at(&mut mind, labels.iter().map(|label| question_n(*label)).collect(), at));
|
||||
}
|
||||
|
||||
let questions = PipelineQuery { kinds: vec![K::Question], ..Default::default() };
|
||||
let page = mind.query(&PipelineQuery { limit: Some(500), ..questions.clone() }).unwrap();
|
||||
assert_eq!(page.matched, 201);
|
||||
assert_eq!(page.items.len(), QUERY_LIMIT_MAX);
|
||||
assert_eq!(page.items[0].admission.admitted_at, "2026-09-17T12:00:00Z", "the earliest batch first");
|
||||
assert_eq!(page.items[0].id.id.0, id("question", "Q10"), "and inside it, key order");
|
||||
assert_ne!(page.items[0].id.id.0, id("question", "Q1"), "Q1 sorts first by id and is admitted last");
|
||||
let last = page
|
||||
.items
|
||||
.iter()
|
||||
.filter(|view| view.admission.admitted_at == "2026-09-17T15:00:00Z")
|
||||
.map(|view| view.id.id.0.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
last,
|
||||
std::iter::once(1).chain(194..=200).map(|label| id("question", &format!("Q{label}"))).collect::<Vec<_>>(),
|
||||
"the last batch in key order, with the one the cap drops missing from its end"
|
||||
);
|
||||
assert_eq!(page.matched as usize - page.items.len(), 1);
|
||||
|
||||
for (limit, expected) in [(None, QUERY_LIMIT_MAX), (Some(500), QUERY_LIMIT_MAX), (Some(0), 1), (Some(5), 5)] {
|
||||
let page = mind.query(&PipelineQuery { limit, ..questions.clone() }).unwrap();
|
||||
assert_eq!(page.items.len(), expected, "limit {limit:?}");
|
||||
assert_eq!(page.matched, 201, "the count is taken before the cap");
|
||||
}
|
||||
}
|
||||
|
||||
/// D6: open work is derived from what is in force and what cites what,
|
||||
/// never maintained.
|
||||
#[test]
|
||||
fn open_items_are_derived_from_in_force_and_citation() {
|
||||
let mut mind = seeded();
|
||||
let q1 = id("question", "Q1");
|
||||
let q2 = id("question", "Q2");
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "A")]));
|
||||
committed(admit(&mut mind, vec![answering("R1", &q2)]));
|
||||
|
||||
// cut-9: a superseded r1 and an r2 with no report.
|
||||
committed(admit(&mut mind, vec![D::CutSpec(cut_spec("9", 1))]));
|
||||
committed(admit(&mut mind, vec![
|
||||
D::CutSpec(cut_spec("9", 2)),
|
||||
resolution(r(K::CutSpec, &id("cut_spec", "cut-9.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-9.r2"))])),
|
||||
]));
|
||||
// cut-10: a spec with a report, and that report with no verdict.
|
||||
committed(admit(&mut mind, vec![D::CutSpec(cut_spec("10", 1))]));
|
||||
committed(admit(&mut mind, vec![D::CutReport(cut_report("10", 1))]));
|
||||
// cut-11: a spec with a report, that report with a verdict, and two
|
||||
// findings, one of which is then fixed.
|
||||
committed(admit(&mut mind, vec![D::CutSpec(cut_spec("11", 1))]));
|
||||
committed(admit(&mut mind, vec![D::CutReport(cut_report("11", 1))]));
|
||||
let f1 = id("finding", "cut-11.s1.F1");
|
||||
let f2 = id("finding", "cut-11.s1.F2");
|
||||
committed(admit(&mut mind, vec![
|
||||
verdict("11", 1, vec![claim(ClaimOutcome::Unproven, &[&f1, &f2], Some("P1"), &["M1"])]),
|
||||
D::Finding(finding("11", 1, "F1", FindingConfidence::Plausible)),
|
||||
D::Finding(finding("11", 1, "F2", FindingConfidence::Plausible)),
|
||||
]));
|
||||
committed(admit(&mut mind, vec![
|
||||
follow_up("FU-1", r(K::Finding, &f1)),
|
||||
resolution(r(K::Finding, &f2), ResolutionOutcome::Fixed { commit: sha(), by: None }),
|
||||
]));
|
||||
committed(admit(&mut mind, elsewhere("Q3")));
|
||||
|
||||
let open = mind.open_items(&slug(CAMPAIGN)).unwrap();
|
||||
assert_eq!(ids(&open.questions), vec![q1], "the answered one is closed, another campaign's is not ours");
|
||||
assert_eq!(ids(&open.findings), vec![f1], "the fixed one is closed");
|
||||
assert_eq!(ids(&open.follow_ups), vec![id("follow_up", "FU-1")]);
|
||||
assert_eq!(
|
||||
ids(&open.specs_without_report),
|
||||
vec![id("cut_spec", "cut-9.r2")],
|
||||
"a superseded revision is not open, and a spec with a report is not either"
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&open.reports_without_verdict),
|
||||
vec![id("cut_report", "cut-10.h1")],
|
||||
"a report is not resolvable, so only the verdict that names it closes it"
|
||||
);
|
||||
}
|
||||
|
||||
/// D5: `semantic` is refused typed, before any filter runs, and never
|
||||
/// answered with an empty page that reads like "nothing matched".
|
||||
#[test]
|
||||
fn semantic_query_refuses_typed_until_wired() {
|
||||
let mind = seeded();
|
||||
let query = PipelineQuery {
|
||||
campaign: Some(slug(CAMPAIGN)),
|
||||
repo: Some(repo(REPO)),
|
||||
cut: Some(l("9")),
|
||||
kinds: vec![K::Question],
|
||||
in_force: Some(true),
|
||||
faculty: Some(Faculty::Hands),
|
||||
admitted_after: Some(s("2026-01-01")),
|
||||
admitted_before: Some(s("2027-01-01")),
|
||||
limit: Some(10),
|
||||
semantic: Some(SemanticQuery { text: "what did we rule about keys".into(), top_k: 5 }),
|
||||
};
|
||||
assert_eq!(
|
||||
mind.query(&query).err(),
|
||||
Some(MindRefusal::Unavailable { detail: "semantic query: the index is not wired (Cut 11)".into() })
|
||||
);
|
||||
assert!(mind.query(&PipelineQuery { semantic: None, ..query }).is_ok());
|
||||
}
|
||||
|
||||
/// D4 and D8: `view` is the typed one-document read with its facts, and an
|
||||
/// id the mind does not hold is `None` rather than a refusal.
|
||||
#[test]
|
||||
fn view_of_an_absent_id_is_none_and_of_a_present_one_is_its_document() {
|
||||
let mut mind = seeded();
|
||||
let q1 = id("question", "Q1");
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
|
||||
assert_eq!(mind.view(&r(K::Question, &id("question", "Q404"))).unwrap(), None);
|
||||
let found = view(&mind, K::Question, &q1);
|
||||
assert_eq!(found.id, r(K::Question, &q1), "the id travels with the document");
|
||||
assert_eq!(Some(found.document), mind.get(K::Question, &q1).unwrap());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue