use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::{Args, Subcommand, ValueEnum};
use crate::comparison::{
self, COMPARISONS_DIR, ComparisonSession, FRAME_EQUAL_EFFORT, FRAME_PREFER_FIRST, Judgement,
RaterKind, ResolutionStatus, Response, RowForm, RowSummary, SessionHeader, Tombstone,
load_sessions,
};
use crate::priority::config::PriorityConfig;
use crate::priority::elicit::{
CandidateKind, DecisionContext, ElicitInputs, ElicitQueue, EntryPayload, FrontierItem,
ItemCosting, Participant, QueueEntry, QueueState, assemble,
};
use crate::priority::graph::{self, PriorityGraph};
use crate::priority::view::ReasonKind;
#[derive(Args)]
pub(crate) struct CompareArgs {
#[command(subcommand)]
pub(crate) action: CompareAction,
}
#[derive(Subcommand)]
pub(crate) enum CompareAction {
Record(RecordArgs),
List(ListArgs),
Withdraw(WithdrawArgs),
Elicit(ElicitArgs),
}
#[derive(Args)]
#[command(group = clap::ArgGroup::new("response").required(true).multiple(false))]
pub(crate) struct RecordArgs {
pub(crate) a: String,
pub(crate) b: String,
#[arg(long, group = "response")]
pub(crate) prefer: Option<String>,
#[arg(long, group = "response")]
pub(crate) equal: bool,
#[arg(long, group = "response")]
pub(crate) incomparable: bool,
#[arg(long)]
pub(crate) supersedes: Option<String>,
#[arg(long, value_enum, default_value_t = FrameArg::EqualEffort)]
pub(crate) frame: FrameArg,
#[arg(long, value_enum, default_value_t = RaterArg::Agent)]
pub(crate) rater: RaterArg,
#[arg(long)]
pub(crate) by: Option<String>,
#[arg(long)]
pub(crate) lens: Option<String>,
#[arg(long)]
pub(crate) note: Option<String>,
#[arg(long)]
pub(crate) audience: Option<String>,
#[arg(short = 'p', long)]
pub(crate) path: Option<PathBuf>,
}
#[derive(Args)]
pub(crate) struct ListArgs {
pub(crate) id: Option<String>,
#[arg(long)]
pub(crate) active_only: bool,
#[arg(short = 'p', long)]
pub(crate) path: Option<PathBuf>,
}
#[derive(Args)]
pub(crate) struct WithdrawArgs {
pub(crate) uid: String,
#[arg(long)]
pub(crate) note: Option<String>,
#[arg(short = 'p', long)]
pub(crate) path: Option<PathBuf>,
}
#[derive(Args)]
pub(crate) struct ElicitArgs {
#[arg(long)]
pub(crate) depth: Option<usize>,
#[arg(long)]
pub(crate) limit: Option<usize>,
#[arg(long, value_enum)]
pub(crate) kind: Option<KindArg>,
#[arg(long)]
pub(crate) json: bool,
#[arg(short = 'p', long)]
pub(crate) path: Option<PathBuf>,
}
#[derive(Clone, Copy, ValueEnum)]
pub(crate) enum KindArg {
Comparison,
AnchorReview,
}
impl KindArg {
fn matches(self, kind: CandidateKind) -> bool {
matches!(
(self, kind),
(KindArg::Comparison, CandidateKind::Comparison)
| (KindArg::AnchorReview, CandidateKind::AnchorReview)
)
}
}
#[derive(Clone, Copy, ValueEnum)]
pub(crate) enum FrameArg {
EqualEffort,
PreferFirst,
}
impl FrameArg {
fn as_frame(self) -> &'static str {
match self {
Self::EqualEffort => FRAME_EQUAL_EFFORT,
Self::PreferFirst => FRAME_PREFER_FIRST,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
pub(crate) enum RaterArg {
Human,
Agent,
}
impl RaterArg {
fn to_kind(self) -> RaterKind {
match self {
Self::Human => RaterKind::Human,
Self::Agent => RaterKind::Agent,
}
}
}
pub(crate) fn run_compare(args: CompareArgs) -> anyhow::Result<()> {
match args.action {
CompareAction::Record(record) => run_capture(&record),
CompareAction::List(list) => run_list(&list),
CompareAction::Withdraw(withdraw) => run_withdraw(&withdraw),
CompareAction::Elicit(elicit) => run_elicit(&elicit),
}
}
fn resolve_participant(root: &Path, raw: &str) -> anyhow::Result<(String, &'static str)> {
let (kref, _id) = crate::integrity::parse_canonical_ref(raw)?;
let (_path, canonical) = crate::commands::facet::resolve_entity_path_and_canonical(root, raw)?;
Ok((canonical, kref.kind.prefix))
}
fn resolve_response(
args: &RecordArgs,
canonical_a: &str,
canonical_b: &str,
) -> anyhow::Result<Response> {
if args.equal {
return Ok(Response::Equal);
}
if args.incomparable {
return Ok(Response::Incomparable);
}
let prefer = args
.prefer
.as_deref()
.ok_or_else(|| anyhow::anyhow!("one of --prefer / --equal / --incomparable is required"))?;
match prefer {
"a" => Ok(Response::PreferA),
"b" => Ok(Response::PreferB),
other if other == canonical_a => Ok(Response::PreferA),
other if other == canonical_b => Ok(Response::PreferB),
other => anyhow::bail!(
"--prefer `{other}` must be `a`, `b`, or one of the two refs ({canonical_a} / {canonical_b})"
),
}
}
fn validate_supersedes(root: &Path, target: &str) -> anyhow::Result<()> {
let sessions = load_sessions(root)?;
let known = sessions
.iter()
.any(|s| s.judgements.iter().any(|j| j.uid == target));
if !known {
anyhow::bail!(
"--supersedes `{target}` names no judgement row — supersession targets an existing row uid (see `compare list`)"
);
}
Ok(())
}
fn run_capture(args: &RecordArgs) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let (canonical_a, kind_a) = resolve_participant(&root, &args.a)?;
let (canonical_b, kind_b) = resolve_participant(&root, &args.b)?;
comparison::admissible_value_pair(kind_a, kind_b).map_err(|reason| anyhow::anyhow!(reason))?;
let response = resolve_response(args, &canonical_a, &canonical_b)?;
let frame = args.frame.as_frame();
let domain = comparison::domain_for_frame(frame)
.ok_or_else(|| anyhow::anyhow!("frame `{frame}` maps to no domain"))?;
if let Some(target) = args.supersedes.as_deref() {
validate_supersedes(&root, target)?;
}
let session_uid = uuid::Uuid::now_v7().to_string();
let row_uid = uuid::Uuid::now_v7().to_string();
let date = crate::clock::today();
let judgement = Judgement {
uid: row_uid,
seq: 0,
a: canonical_a,
b: canonical_b,
response,
domain: domain.to_string(),
frame: frame.to_string(),
form: RowForm::Order,
magnitude: None,
supersedes: args.supersedes.clone(),
lens: args.lens.clone(),
rater: args.rater.to_kind(),
by: args.by.clone(),
note: args.note.clone(),
date: date.clone(),
};
comparison::validate_judgement(&judgement)?;
let session = comparison::session_of_one(
SessionHeader {
uid: session_uid.clone(),
date: date.clone(),
audience: args.audience.clone(),
},
judgement,
);
let text = comparison::to_toml(&session)?;
let path = write_session_file(&root, &date, &session_uid, &text)?;
writeln!(
std::io::stdout(),
"compare: captured {} — session {}",
path.display(),
session_uid
)?;
Ok(())
}
fn write_session_file(
root: &Path,
date: &str,
session_uid: &str,
text: &str,
) -> anyhow::Result<PathBuf> {
use std::io::Write;
let dir = root.join(".doctrine").join(COMPARISONS_DIR);
std::fs::create_dir_all(&dir)
.with_context(|| format!("create comparisons dir {}", dir.display()))?;
let path = dir.join(format!("{date}-{session_uid}.toml"));
let mut file = crate::fsutil::create_new_file(&path)
.with_context(|| format!("write comparison session {}", path.display()))?;
file.write_all(text.as_bytes())
.with_context(|| format!("write comparison session {}", path.display()))?;
Ok(path)
}
fn render_row(row: &RowSummary) -> String {
let pair = match row.response {
Response::PreferA => format!("{}* vs {}", row.a, row.b),
Response::PreferB => format!("{} vs {}*", row.a, row.b),
Response::Equal => format!("{} = {}", row.a, row.b),
Response::Incomparable => format!("{} ~ {}", row.a, row.b),
};
let by = row
.by
.as_deref()
.map(|b| format!(":{b}"))
.unwrap_or_default();
let note = row
.note
.as_deref()
.map(|n| format!(" note: {n}"))
.unwrap_or_default();
let withdrawn = matches!(row.state.resolution, ResolutionStatus::Tombstoned);
let tomb = if withdrawn { " [withdrawn]" } else { "" };
format!(
"{uid} status={status} {pair} frame={frame} rater={rater}{by} {date}{note}{tomb}",
uid = row.uid,
status = row.state.display_token(),
frame = row.frame,
rater = row.rater_token,
date = row.date,
)
}
fn list_lines(rows: &[RowSummary], filter: Option<&str>, active_only: bool) -> Vec<String> {
rows.iter()
.filter(|r| filter.is_none_or(|id| r.a == id || r.b == id))
.filter(|r| !active_only || matches!(r.state.resolution, ResolutionStatus::Active))
.map(render_row)
.collect()
}
fn run_list(args: &ListArgs) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let pipeline = crate::priority::graph::load_comparison_pipeline_for_root(&root)?;
let lines = list_lines(&pipeline.rows, args.id.as_deref(), args.active_only);
let mut out = std::io::stdout();
if lines.is_empty() {
writeln!(out, "compare: no judgements recorded")?;
return Ok(());
}
for line in lines {
writeln!(out, "{line}")?;
}
Ok(())
}
fn run_withdraw(args: &WithdrawArgs) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let sessions = load_sessions(&root)?;
let Some(target_session) = sessions
.iter()
.find(|s| s.judgements.iter().any(|j| j.uid == args.uid))
else {
let is_tombstone = sessions
.iter()
.any(|s| s.tombstones.iter().any(|t| t.uid == args.uid));
if is_tombstone {
anyhow::bail!(
"{} is a tombstone, not a judgement — withdraw targets judgement rows",
args.uid
);
}
anyhow::bail!("unknown row uid `{}` — no judgement carries it", args.uid);
};
let already = sessions
.iter()
.any(|s| s.tombstones.iter().any(|t| t.target == args.uid));
if already {
anyhow::bail!("{} is already withdrawn", args.uid);
}
let session_uid = uuid::Uuid::now_v7().to_string();
let tombstone_uid = uuid::Uuid::now_v7().to_string();
let date = crate::clock::today();
let session = ComparisonSession {
schema: target_session.schema.clone(),
version: target_session.version,
session: SessionHeader {
uid: session_uid.clone(),
date: date.clone(),
audience: None,
},
judgements: Vec::new(),
tombstones: vec![Tombstone {
uid: tombstone_uid,
seq: 0,
target: args.uid.clone(),
date: date.clone(),
note: args.note.clone(),
}],
};
let text = comparison::to_toml(&session)?;
let path = write_session_file(&root, &date, &session_uid, &text)?;
writeln!(
std::io::stdout(),
"compare: withdrew {} — tombstone {}",
args.uid,
path.display()
)?;
Ok(())
}
fn run_elicit(args: &ElicitArgs) -> anyhow::Result<()> {
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let cfg = crate::priority::config::load(&root);
let graph = graph::build(&root)?;
let pipeline = graph::load_comparison_pipeline_for_root(&root)?;
let depth = args.depth.unwrap_or(cfg.elicit.depth).max(1);
let inputs = build_elicit_inputs(&graph, &pipeline, &cfg, depth);
let queue = assemble(&inputs, DecisionContext::Sequencing { depth });
let ctx = RenderCtx::new(&graph, &pipeline, &cfg);
if args.json {
render_elicit_json(&queue, depth, args, &ctx)
} else {
render_elicit_human(&queue, args, &ctx)
}
}
fn build_elicit_inputs<'a>(
graph: &PriorityGraph,
pipeline: &'a comparison::Pipeline,
cfg: &PriorityConfig,
depth: usize,
) -> ElicitInputs<'a> {
let mut ranked: Vec<(&_, f64)> = graph.score.iter().map(|(k, &s)| (k, s)).collect();
ranked.sort_by(|(ka, sa), (kb, sb)| sb.total_cmp(sa).then_with(|| ka.cmp(kb)));
let frontier: Vec<FrontierItem> = ranked
.iter()
.take(depth)
.filter_map(|(key, _)| {
graph.attrs.get(key).map(|attr| FrontierItem {
id: key.canonical(),
kind: attr.kind.prefix.to_string(),
})
})
.collect();
let mut costing: std::collections::BTreeMap<String, ItemCosting> =
std::collections::BTreeMap::new();
for key in graph.attrs.keys() {
if let Some((multiplier, est_cost, bare_estimate)) = graph.item_costing(key, cfg) {
costing.insert(
key.canonical(),
ItemCosting {
multiplier,
est_cost,
bare_estimate,
},
);
}
}
ElicitInputs {
active: pipeline.active_judgements.iter().collect(),
anchors: pipeline.anchors.clone(),
frontier,
costing,
projection: pipeline.projection.clone(),
rank_decay: cfg.elicit.rank_decay,
confirm_boost: cfg.elicit.confirm_boost,
}
}
fn displayed<'q>(queue: &'q ElicitQueue, args: &ElicitArgs) -> Vec<&'q QueueEntry> {
let limit = args.limit.unwrap_or(crate::priority::config::ELICIT_LIMIT);
queue
.entries
.iter()
.filter(|e| args.kind.is_none_or(|k| k.matches(e.kind)))
.take(limit)
.collect()
}
const MASK_MARK: &str = "⚠";
fn state_footer(queue: &ElicitQueue) -> (&'static str, String) {
match &queue.state {
QueueState::Candidates => (
"candidates",
"candidates outstanding — comparisons / anchor-reviews worth asking".to_string(),
),
QueueState::Stalled { depth } => (
"stalled",
format!(
"stalled at depth {depth}: greedy one-step yield exhausted — NOT a stability \
claim (a bridge question may remain)"
),
),
QueueState::Stable { depth } => {
let base = format!(
"stable: value_dim order among the current top-{depth} frontier members is \
stable over the joint set (internal order, current members — not membership)"
);
let scope = match queue.excluded_value_insensitive {
0 => String::new(),
n => format!("; {n} pair(s) value-insensitive (zero weight), outside the claim"),
};
("stable", format!("{base}{scope}"))
}
}
}
fn render_elicit_human(
queue: &ElicitQueue,
args: &ElicitArgs,
ctx: &RenderCtx<'_>,
) -> anyhow::Result<()> {
use std::io::Write;
let mut out = std::io::stdout();
let shown = displayed(queue, args);
if shown.is_empty() {
writeln!(out, "elicit: no candidates")?;
}
for (i, entry) in shown.iter().enumerate() {
write!(out, "{}", entry_human(i + 1, entry, ctx))?;
}
let (_, detail) = state_footer(queue);
writeln!(out, "state: {detail}")?;
Ok(())
}
fn entry_human(rank: usize, entry: &QueueEntry, ctx: &RenderCtx<'_>) -> String {
let kind = match entry.kind {
CandidateKind::Comparison => "comparison",
CandidateKind::AnchorReview => "anchor-review",
};
let mut parts = vec![format!(
"{rank}. [{kind}] score {:.3} yield {:+} impact {:.3}\n",
entry.score, entry.guaranteed_yield, entry.guaranteed_impact
)];
match &entry.payload {
EntryPayload::Comparison { a, b, .. } => {
parts.push(format!(" ask: {} vs {}\n", a.id, b.id));
parts.push(participant_human(ctx, a));
parts.push(participant_human(ctx, b));
}
EntryPayload::AnchorReview { subject, .. } => {
let anchor = subject
.anchor
.map_or_else(|| "—".to_string(), |v| format!("{v}"));
parts.push(format!(
" review: anchor on {} (value {anchor})\n",
subject.id
));
}
}
let reasons: Vec<&str> = entry.reasons.iter().map(|r| r.text.as_str()).collect();
if !reasons.is_empty() {
parts.push(format!(" reasons: {}\n", reasons.join("; ")));
}
parts.push(format!(" answer: {}\n", answer_command(entry)));
parts.concat()
}
fn participant_human(ctx: &RenderCtx<'_>, p: &Participant) -> String {
let (title, status) = ctx.context(&p.id).unwrap_or(("", None));
let status = status.unwrap_or("—");
let value = ctx
.value_fragment(&p.id)
.unwrap_or_else(|| "value —".to_string());
let est = ctx.estimate_display(&p.id);
let mut parts = vec![format!(
" • {} \"{title}\" ({status})\n {value} · {est}\n",
p.id
)];
if !p.annotations.is_empty() {
parts.push(format!(" {MASK_MARK} {}\n", p.annotations.join("; ")));
}
parts.concat()
}
fn answer_command(entry: &QueueEntry) -> String {
match &entry.payload {
EntryPayload::Comparison { a, b, .. } => format!(
"doctrine compare record {} {} --prefer a (| --prefer b | --equal | --incomparable)",
a.id, b.id
),
EntryPayload::AnchorReview { subject, .. } => format!(
"doctrine value set {} <v> (revise) | supersede/withdraw the cited rows (uphold)",
subject.id
),
}
}
struct RenderCtx<'a> {
graph: &'a PriorityGraph,
pipeline: &'a comparison::Pipeline,
cfg: &'a PriorityConfig,
keys: std::collections::BTreeMap<String, crate::relation_graph::EntityKey>,
}
impl<'a> RenderCtx<'a> {
fn new(
graph: &'a PriorityGraph,
pipeline: &'a comparison::Pipeline,
cfg: &'a PriorityConfig,
) -> Self {
let keys = graph.attrs.keys().map(|k| (k.canonical(), *k)).collect();
Self {
graph,
pipeline,
cfg,
keys,
}
}
fn value_block(&self, canonical: &str) -> Option<serde_json::Value> {
let key = *self.keys.get(canonical)?;
let (provenance, point) =
match crate::priority::surface::value_source_reason(self.graph, key, self.pipeline)? {
ReasonKind::ValueAuthored { value, .. } => ("authored", value),
ReasonKind::ValueProjected { value, .. } => ("projected", value),
ReasonKind::ValueGauge { value, .. } => ("gauge", value),
_ => return None,
};
let bounds = crate::priority::surface::class_bounds_structural(
&self.pipeline.constraint_set,
canonical,
);
Some(value_block_json(provenance, point, bounds))
}
fn estimate(&self, canonical: &str) -> Option<f64> {
let key = *self.keys.get(canonical)?;
let (_, est_cost, bare) = self.graph.item_costing(&key, self.cfg)?;
(!bare).then_some(est_cost)
}
fn context(&self, canonical: &str) -> Option<(&str, Option<&str>)> {
let key = self.keys.get(canonical)?;
let attr = self.graph.attrs.get(key)?;
Some((attr.title.as_str(), attr.status.as_deref()))
}
fn value_fragment(&self, canonical: &str) -> Option<String> {
let key = *self.keys.get(canonical)?;
let reason = crate::priority::surface::value_source_reason(self.graph, key, self.pipeline)?;
crate::priority::render::value_source_fragment(&reason)
}
fn estimate_display(&self, canonical: &str) -> String {
match self
.keys
.get(canonical)
.and_then(|k| self.graph.item_costing(k, self.cfg))
{
Some((_, est, false)) => format!("est {est:.2}"),
_ => "est —".to_string(),
}
}
fn participant_json(&self, p: &Participant) -> serde_json::Value {
serde_json::json!({
"id": p.id,
"value": self.value_block(&p.id),
"estimate": self.estimate(&p.id),
"annotations": p.annotations,
})
}
}
fn render_elicit_json(
queue: &ElicitQueue,
depth: usize,
args: &ElicitArgs,
ctx: &RenderCtx<'_>,
) -> anyhow::Result<()> {
use std::io::Write;
let text = serde_json::to_string_pretty(&elicit_envelope(queue, depth, args, ctx))?;
write!(std::io::stdout(), "{text}")?;
Ok(())
}
fn elicit_envelope(
queue: &ElicitQueue,
depth: usize,
args: &ElicitArgs,
ctx: &RenderCtx<'_>,
) -> serde_json::Value {
let (state_token, state_detail) = state_footer(queue);
let entries: Vec<serde_json::Value> = displayed(queue, args)
.iter()
.enumerate()
.map(|(i, e)| entry_json(i + 1, e, ctx))
.collect();
serde_json::json!({
"schema": "doctrine.elicit-queue",
"version": 1,
"context": { "kind": "sequencing", "depth": depth },
"state": state_token,
"state_detail": state_detail,
"excluded_value_insensitive": queue.excluded_value_insensitive,
"entries": entries,
})
}
fn entry_json(rank: usize, entry: &QueueEntry, ctx: &RenderCtx<'_>) -> serde_json::Value {
let kind = match entry.kind {
CandidateKind::Comparison => "comparison",
CandidateKind::AnchorReview => "anchor-review",
};
let reasons: Vec<serde_json::Value> = entry
.reasons
.iter()
.map(|r| serde_json::json!({ "code": r.code, "text": r.text }))
.collect();
let payload = match &entry.payload {
EntryPayload::Comparison { a, b, ask } => serde_json::json!({
"participants": [ctx.participant_json(a), ctx.participant_json(b)],
"ask": comparison_ask_json(ask),
}),
EntryPayload::AnchorReview { subject, ask } => serde_json::json!({
"subject": {
"id": subject.id,
"anchor": subject.anchor,
"conflict_pairs": subject.conflict_pairs,
"quarantined_rows": subject.quarantined_rows,
},
"ask": anchor_ask_json(ask, subject),
}),
};
let mut obj = serde_json::json!({
"rank": rank,
"kind": kind,
"guaranteed_yield": entry.guaranteed_yield,
"guaranteed_impact": entry.guaranteed_impact,
"score": entry.score,
"yield_basis": match entry.yield_basis {
crate::priority::elicit::YieldBasis::OrderBearingAnswers => "order-bearing-answers",
crate::priority::elicit::YieldBasis::CanonicalResolvingActions => {
"canonical-resolving-actions"
}
},
"reasons": reasons,
});
if let (Some(map), serde_json::Value::Object(pmap)) = (obj.as_object_mut(), payload) {
for (k, v) in pmap {
map.insert(k, v);
}
}
obj
}
fn comparison_ask_json(ask: &crate::priority::elicit::AskSpec) -> serde_json::Value {
serde_json::json!({
"frame": FRAME_EQUAL_EFFORT,
"domain": comparison::DOMAIN_VALUE,
"answers": ask.answers,
"yield_by_answer": ask.yield_by_answer,
})
}
fn anchor_ask_json(
ask: &crate::priority::elicit::AskSpec,
subject: &crate::priority::elicit::AnchorSubject,
) -> serde_json::Value {
serde_json::json!({
"answers": ask.answers,
"yield_by_answer": ask.yield_by_answer,
"yield_note": ask.yield_note,
"exits": anchor_exits_json(subject),
})
}
fn anchor_exits_json(subject: &crate::priority::elicit::AnchorSubject) -> serde_json::Value {
let revise = vec![format!("doctrine value set {} <v>", subject.id)];
let mut uphold: Vec<String> = Vec::new();
if let Some(first) = subject.quarantined_rows.first() {
uphold.push(format!(
"doctrine compare record <a> <b> <response> --supersedes {first}"
));
}
for uid in &subject.quarantined_rows {
uphold.push(format!("doctrine compare withdraw {uid}"));
}
serde_json::json!({
crate::priority::elicit::ANSWER_REVISE_ANCHOR: revise,
crate::priority::elicit::ANSWER_UPHOLD_ANCHOR: uphold,
})
}
fn bound_json(b: comparison::Bound) -> serde_json::Value {
match b {
comparison::Bound::Unbounded => serde_json::json!({ "kind": "unbounded" }),
comparison::Bound::Open(v) => serde_json::json!({ "kind": "open", "value": v }),
comparison::Bound::Closed(v) => serde_json::json!({ "kind": "closed", "value": v }),
}
}
fn value_block_json(
provenance: &str,
point: f64,
bounds: Option<comparison::ValueBounds>,
) -> serde_json::Value {
let mut v = serde_json::json!({ "provenance": provenance, "point": point });
if let (Some(b), Some(map)) = (bounds, v.as_object_mut()) {
map.insert(
"bounds".to_string(),
serde_json::json!({ "lower": bound_json(b.lower), "upper": bound_json(b.upper) }),
);
}
v
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "test code")]
mod tests {
use super::*;
fn mk_project_root() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".project"), "").unwrap();
std::fs::create_dir_all(tmp.path().join(".doctrine")).unwrap();
std::fs::write(tmp.path().join(crate::dtoml::DOCTRINE_TOML), "").unwrap();
let root = tmp.path().to_path_buf();
(tmp, root)
}
fn seed_entity(root: &Path, prefix: &str, id: u32, status: &str) -> String {
let padded = format!("{id:03}");
let kref = crate::integrity::kind_by_prefix(prefix).expect("valid prefix");
let toml_path = crate::entity::id_path(root, kref.kind, id, crate::entity::Ext::Toml);
std::fs::create_dir_all(toml_path.parent().unwrap()).unwrap();
std::fs::write(
&toml_path,
format!(
"id = {id}\nslug = \"t{padded}\"\ntitle = \"Test {prefix}-{padded}\"\nstatus = \"{status}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n"
),
)
.unwrap();
crate::listing::canonical_id(prefix, id)
}
fn session_files(root: &Path) -> Vec<PathBuf> {
let dir = root.join(".doctrine").join(COMPARISONS_DIR);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
files.sort();
files
}
fn capture(root: &Path, a: &str, b: &str, prefer: &str) -> RecordArgs {
RecordArgs {
a: a.to_string(),
b: b.to_string(),
prefer: Some(prefer.to_string()),
equal: false,
incomparable: false,
supersedes: None,
frame: FrameArg::EqualEffort,
rater: RaterArg::Agent,
by: None,
lens: None,
note: None,
audience: None,
path: Some(root.to_path_buf()),
}
}
#[test]
fn compare_capture_writes_session_of_one() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
let files = session_files(&root);
assert_eq!(files.len(), 1, "exactly one session file");
let name = files[0].file_name().unwrap().to_string_lossy();
assert!(
name.ends_with(".toml") && name.contains('-'),
"filename is <date>-<uid>.toml: {name}"
);
let session = comparison::parse(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();
assert_eq!(session.judgements.len(), 1);
assert!(session.tombstones.is_empty());
let j = &session.judgements[0];
assert_eq!(j.seq, 0);
assert_eq!(j.a, "SL-204");
assert_eq!(j.b, "IMP-118");
assert_eq!(j.response, Response::PreferA);
assert_eq!(j.domain, comparison::DOMAIN_VALUE);
assert_eq!(j.frame, FRAME_EQUAL_EFFORT);
assert_eq!(j.form, RowForm::Order);
assert_eq!(j.rater, RaterKind::Agent);
assert_eq!(j.magnitude, None);
assert_eq!(j.supersedes, None);
}
#[test]
fn second_capture_never_touches_first_file() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
let first = session_files(&root);
assert_eq!(first.len(), 1);
let first_path = first[0].clone();
let first_bytes = std::fs::read(&first_path).unwrap();
run_capture(&capture(&root, "SL-204", "IMP-118", "b")).unwrap();
let second = session_files(&root);
assert_eq!(second.len(), 2, "a fresh file per invocation");
assert!(first_path.exists(), "the first file survives");
assert_eq!(
std::fs::read(&first_path).unwrap(),
first_bytes,
"the first file is byte-identical after the second capture"
);
}
#[test]
fn compare_refuses_missing_ref() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
let err = run_capture(&capture(&root, "SL-204", "SL-999", "a")).unwrap_err();
assert!(!err.to_string().is_empty());
assert!(session_files(&root).is_empty(), "no file on a dangling ref");
}
#[test]
fn compare_refuses_record_pair() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "QUE", 1, "open");
seed_entity(&root, "QUE", 2, "open");
let err = run_capture(&capture(&root, "QUE-001", "QUE-002", "a")).unwrap_err();
assert!(
err.to_string().contains("not value-bearing"),
"admissibility reason surfaced: {err}"
);
assert!(session_files(&root).is_empty(), "no file on a record pair");
}
#[test]
fn compare_refuses_rsk_participant() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "RSK", 1, "open");
let err = run_capture(&capture(&root, "SL-204", "RSK-001", "a")).unwrap_err();
assert!(
err.to_string().contains("excluded from value comparison"),
"RSK admissibility reason surfaced: {err}"
);
assert!(
session_files(&root).is_empty(),
"no file on an RSK participant"
);
}
#[test]
fn compare_admits_terminal_status_participant() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "closed");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
assert_eq!(
session_files(&root).len(),
1,
"terminal-status participant admitted"
);
}
#[test]
fn flag_surface_lands_frame_rater_by() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
let args = RecordArgs {
frame: FrameArg::PreferFirst,
rater: RaterArg::Human,
by: Some("david".to_string()),
lens: Some("user-value".to_string()),
note: Some("auth unblocks the pilot".to_string()),
..capture(&root, "SL-204", "IMP-118", "a")
};
run_capture(&args).unwrap();
let files = session_files(&root);
let session = comparison::parse(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();
let j = &session.judgements[0];
assert_eq!(j.frame, FRAME_PREFER_FIRST);
assert_eq!(j.rater, RaterKind::Human);
assert_eq!(j.by.as_deref(), Some("david"));
assert_eq!(j.lens.as_deref(), Some("user-value"));
assert_eq!(j.note.as_deref(), Some("auth unblocks the pilot"));
}
#[test]
fn prefer_literal_shorthand() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
run_capture(&capture(&root, "SL-204", "IMP-118", "b")).unwrap();
let mut responses: Vec<Response> = session_files(&root)
.iter()
.map(|p| {
comparison::parse(&std::fs::read_to_string(p).unwrap())
.unwrap()
.judgements[0]
.response
})
.collect();
responses.sort_by_key(|r| format!("{r:?}"));
assert_eq!(responses, vec![Response::PreferA, Response::PreferB]);
}
#[test]
fn prefer_full_ref_resolves() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "IMP-118")).unwrap();
let files = session_files(&root);
let session = comparison::parse(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();
assert_eq!(session.judgements[0].response, Response::PreferB);
}
#[test]
fn equal_and_incomparable_capture() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
let equal = RecordArgs {
prefer: None,
equal: true,
..capture(&root, "SL-204", "IMP-118", "unused")
};
run_capture(&equal).unwrap();
let incomparable = RecordArgs {
prefer: None,
incomparable: true,
..capture(&root, "SL-204", "IMP-118", "unused")
};
run_capture(&incomparable).unwrap();
let mut responses: Vec<Response> = session_files(&root)
.iter()
.map(|p| {
let s = comparison::parse(&std::fs::read_to_string(p).unwrap()).unwrap();
assert_eq!(s.judgements[0].domain, comparison::DOMAIN_VALUE);
s.judgements[0].response
})
.collect();
responses.sort_by_key(|r| format!("{r:?}"));
assert_eq!(responses, vec![Response::Equal, Response::Incomparable]);
}
#[test]
fn prefer_first_frame_derives_priority_domain() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
let args = RecordArgs {
frame: FrameArg::PreferFirst,
..capture(&root, "SL-204", "IMP-118", "a")
};
run_capture(&args).unwrap();
let files = session_files(&root);
let session = comparison::parse(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();
let j = &session.judgements[0];
assert_eq!(j.domain, comparison::DOMAIN_PRIORITY);
assert_eq!(j.frame, FRAME_PREFER_FIRST);
}
#[test]
fn supersedes_validated_against_corpus() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
let unknown = RecordArgs {
supersedes: Some("not-a-real-uid".to_string()),
..capture(&root, "SL-204", "IMP-118", "a")
};
let err = run_capture(&unknown).unwrap_err();
assert!(
err.to_string().contains("names no judgement row"),
"unknown supersedes target refused: {err}"
);
assert!(session_files(&root).is_empty(), "no file on a bad target");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
let target = only_judgement_uid(&root);
let known = RecordArgs {
supersedes: Some(target.clone()),
..capture(&root, "SL-204", "IMP-118", "b")
};
run_capture(&known).unwrap();
let superseding: Vec<Option<String>> = session_files(&root)
.iter()
.map(|p| {
comparison::parse(&std::fs::read_to_string(p).unwrap())
.unwrap()
.judgements[0]
.supersedes
.clone()
})
.collect();
assert!(
superseding.contains(&Some(target)),
"the superseding row carries the target uid"
);
}
#[test]
fn prefer_bogus_value_refused() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
let err = run_capture(&capture(&root, "SL-204", "IMP-118", "SL-999")).unwrap_err();
assert!(err.to_string().contains("--prefer"), "got: {err}");
assert!(session_files(&root).is_empty(), "no file on a bad --prefer");
}
#[test]
fn compare_refuses_bare_ref() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
let err = run_capture(&capture(&root, "204", "IMP-118", "a")).unwrap_err();
assert!(!err.to_string().is_empty());
assert!(session_files(&root).is_empty(), "no file on a bare ref");
}
#[test]
fn audience_lands_in_session_header() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
let with_aud = RecordArgs {
audience: Some("stakeholder".to_string()),
..capture(&root, "SL-204", "IMP-118", "a")
};
run_capture(&with_aud).unwrap();
let files = session_files(&root);
let text = std::fs::read_to_string(&files[0]).unwrap();
let session = comparison::parse(&text).unwrap();
assert_eq!(session.session.audience.as_deref(), Some("stakeholder"));
std::fs::remove_file(&files[0]).unwrap();
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
let files = session_files(&root);
let text = std::fs::read_to_string(&files[0]).unwrap();
assert!(
!text.contains("audience"),
"no audience key when flag absent:\n{text}"
);
let session = comparison::parse(&text).unwrap();
assert!(session.session.audience.is_none());
}
#[test]
fn clap_shape_accepts_record_and_subcommands() {
use clap::Parser;
#[derive(Parser)]
struct Wrap {
#[command(subcommand)]
action: CompareAction,
}
let w = Wrap::try_parse_from(["x", "record", "SL-204", "IMP-118", "--prefer", "a"])
.expect("record form parses");
let CompareAction::Record(r) = w.action else {
panic!("expected record");
};
assert_eq!(r.a, "SL-204");
assert_eq!(r.prefer.as_deref(), Some("a"));
for arm in ["--equal", "--incomparable"] {
Wrap::try_parse_from(["x", "record", "SL-204", "IMP-118", arm])
.unwrap_or_else(|e| panic!("{arm} parses alone: {e}"));
}
assert!(
Wrap::try_parse_from([
"x", "record", "SL-204", "IMP-118", "--prefer", "a", "--equal"
])
.is_err(),
"two response arms refused"
);
assert!(
Wrap::try_parse_from([
"x",
"record",
"SL-204",
"IMP-118",
"--equal",
"--incomparable"
])
.is_err(),
"two flag arms refused"
);
assert!(
Wrap::try_parse_from(["x", "record", "SL-204", "IMP-118"]).is_err(),
"a response arm is required"
);
let w = Wrap::try_parse_from(["x", "list"]).expect("list subcommand parses");
assert!(matches!(w.action, CompareAction::List(_)));
let w = Wrap::try_parse_from(["x", "withdraw", "some-uid"])
.expect("withdraw subcommand parses");
assert!(matches!(w.action, CompareAction::Withdraw(_)));
assert!(
Wrap::try_parse_from(["x", "record"]).is_err(),
"record demands its positionals"
);
}
fn mk_judgement(uid: &str, seq: u32, a: &str, b: &str) -> Judgement {
Judgement {
uid: uid.to_string(),
seq,
a: a.to_string(),
b: b.to_string(),
response: Response::PreferA,
domain: comparison::DOMAIN_VALUE.to_string(),
frame: FRAME_EQUAL_EFFORT.to_string(),
form: RowForm::Order,
magnitude: None,
supersedes: None,
lens: None,
rater: RaterKind::Agent,
by: None,
note: None,
date: "2026-07-10".to_string(),
}
}
fn mk_session(date: &str, uid: &str, judgements: Vec<Judgement>) -> ComparisonSession {
ComparisonSession {
schema: comparison::COMPARISON_SCHEMA.to_string(),
version: comparison::COMPARISON_VERSION,
session: SessionHeader {
uid: uid.to_string(),
date: date.to_string(),
audience: None,
},
judgements,
tombstones: Vec::new(),
}
}
fn rows_of(sessions: &[ComparisonSession]) -> Vec<RowSummary> {
comparison::pipeline_from_sessions(
sessions,
&comparison::StatusMap::new(),
&comparison::AnchorMap::new(),
&comparison::ProjectionCfg {
gauge_step: 0.25,
default_value: 1.0,
},
)
.unwrap()
.rows
}
fn withdraw_args(root: &Path, uid: &str, note: Option<&str>) -> WithdrawArgs {
WithdrawArgs {
uid: uid.to_string(),
note: note.map(str::to_string),
path: Some(root.to_path_buf()),
}
}
fn only_judgement_uid(root: &Path) -> String {
load_sessions(root)
.unwrap()
.iter()
.flat_map(|s| s.judgements.iter())
.map(|j| j.uid.clone())
.next()
.expect("a judgement row exists")
}
fn only_tombstone_uid(root: &Path) -> String {
load_sessions(root)
.unwrap()
.iter()
.flat_map(|s| s.tombstones.iter())
.map(|t| t.uid.clone())
.next()
.expect("a tombstone row exists")
}
fn mk_judgement_dated(uid: &str, seq: u32, a: &str, b: &str, date: &str) -> Judgement {
let mut j = mk_judgement(uid, seq, a, b);
j.date = date.to_string();
j
}
#[test]
fn compare_list_orders_by_total_key() {
let late = mk_session(
"2026-07-10",
"sess-b",
vec![mk_judgement_dated(
"row-late",
0,
"SL-204",
"IMP-118",
"2026-07-10",
)],
);
let early = mk_session(
"2026-07-08",
"sess-a",
vec![mk_judgement_dated(
"row-early",
0,
"SL-204",
"CHR-042",
"2026-07-08",
)],
);
let same_date = mk_session(
"2026-07-08",
"sess-c",
vec![
mk_judgement_dated("row-seq1", 1, "IDE-001", "IMP-118", "2026-07-08"),
mk_judgement_dated("row-seq0", 0, "IDE-001", "SL-204", "2026-07-08"),
],
);
let lines = list_lines(&rows_of(&[late, same_date, early]), None, false);
let order: Vec<&str> = lines
.iter()
.map(|l| l.split_whitespace().next().unwrap())
.collect();
assert_eq!(order, ["row-early", "row-seq0", "row-seq1", "row-late"]);
}
#[test]
fn compare_list_filters_by_participant() {
let session = mk_session(
"2026-07-10",
"sess-1",
vec![
mk_judgement("keep", 0, "SL-204", "IMP-118"),
mk_judgement("drop", 1, "SL-999", "CHR-042"),
],
);
let lines = list_lines(&rows_of(&[session]), Some("IMP-118"), false);
assert_eq!(lines.len(), 1, "only the participating row survives");
assert!(
lines[0].contains("keep"),
"kept the IMP-118 row: {}",
lines[0]
);
}
#[test]
fn list_renders_full_row_uid() {
let uid = "0197f3a2-6c2f-7d4e-8f5a-2b3c4d5e6f7a";
let session = mk_session(
"2026-07-10",
"sess-1",
vec![mk_judgement(uid, 0, "SL-204", "IMP-118")],
);
let lines = list_lines(&rows_of(&[session]), None, false);
assert_eq!(lines.len(), 1);
assert!(
lines[0].contains(uid),
"full uid rendered, not a prefix: {}",
lines[0]
);
}
#[test]
fn withdraw_appends_tombstone() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
let before = session_files(&root);
assert_eq!(before.len(), 1);
let orig_path = before[0].clone();
let orig_bytes = std::fs::read(&orig_path).unwrap();
let uid = only_judgement_uid(&root);
run_withdraw(&withdraw_args(&root, &uid, Some("wrong way round"))).unwrap();
let after = session_files(&root);
assert_eq!(after.len(), 2, "a fresh tombstone file was appended");
assert!(orig_path.exists());
assert_eq!(
std::fs::read(&orig_path).unwrap(),
orig_bytes,
"the judgement file is untouched by withdraw"
);
let sessions = load_sessions(&root).unwrap();
let tomb = sessions
.iter()
.flat_map(|s| s.tombstones.iter())
.find(|t| t.target == uid)
.expect("tombstone targets the withdrawn row");
assert_eq!(tomb.seq, 0);
assert_eq!(tomb.note.as_deref(), Some("wrong way round"));
let lines = list_lines(&rows_of(&sessions), None, false);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains(&uid), "full uid present: {}", lines[0]);
assert!(
lines[0].contains("[withdrawn]"),
"row rendered withdrawn: {}",
lines[0]
);
}
#[test]
fn refuses_double_withdraw() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
let uid = only_judgement_uid(&root);
run_withdraw(&withdraw_args(&root, &uid, None)).unwrap();
let err = run_withdraw(&withdraw_args(&root, &uid, None)).unwrap_err();
assert!(
err.to_string().contains("already withdrawn"),
"second withdraw refused: {err}"
);
assert_eq!(session_files(&root).len(), 2, "refusal writes no file");
}
#[test]
fn withdraw_refuses_unknown_and_tombstone_row() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 204, "accepted");
seed_entity(&root, "IMP", 118, "accepted");
run_capture(&capture(&root, "SL-204", "IMP-118", "a")).unwrap();
let uid = only_judgement_uid(&root);
let err = run_withdraw(&withdraw_args(&root, "not-a-real-uid", None)).unwrap_err();
assert!(
err.to_string().contains("unknown row uid"),
"unknown uid refused: {err}"
);
run_withdraw(&withdraw_args(&root, &uid, None)).unwrap();
let tomb_uid = only_tombstone_uid(&root);
let err = run_withdraw(&withdraw_args(&root, &tomb_uid, None)).unwrap_err();
assert!(
err.to_string().contains("tombstone"),
"tombstone-row uid refused: {err}"
);
}
#[test]
fn list_empty_ledger_is_not_an_error() {
let (_tmp, root) = mk_project_root();
let sessions = load_sessions(&root).unwrap();
assert!(sessions.is_empty());
assert!(list_lines(&rows_of(&sessions), None, false).is_empty());
run_list(&ListArgs {
id: None,
active_only: false,
path: Some(root),
})
.unwrap();
}
fn seed_scored_slice(root: &Path, id: u32) -> String {
let p = format!("{id:03}");
let dir = root.join(".doctrine").join("slice").join(&p);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("slice-{p}.toml")),
format!(
"id = {id}\nslug = \"s{p}\"\ntitle = \"S\"\nstatus = \"proposed\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n"
),
)
.unwrap();
std::fs::write(dir.join(format!("slice-{p}.md")), "scope\n").unwrap();
crate::listing::canonical_id("SL", id)
}
fn elicit_args(root: &Path) -> ElicitArgs {
ElicitArgs {
depth: None,
limit: None,
kind: None,
json: false,
path: Some(root.to_path_buf()),
}
}
#[test]
fn build_elicit_inputs_is_deterministic_and_d6_costed() {
let (_tmp, root) = mk_project_root();
let id1 = seed_scored_slice(&root, 1);
let id2 = seed_scored_slice(&root, 2);
let cfg = crate::priority::config::load(&root);
let graph = graph::build(&root).unwrap();
let pipeline = graph::load_comparison_pipeline_for_root(&root).unwrap();
let a = build_elicit_inputs(&graph, &pipeline, &cfg, 8);
let b = build_elicit_inputs(&graph, &pipeline, &cfg, 8);
assert_eq!(format!("{a:?}"), format!("{b:?}"));
let ids: Vec<&String> = a.frontier.iter().map(|f| &f.id).collect();
assert!(ids.contains(&&id1) && ids.contains(&&id2));
assert_eq!(a.frontier.first().map(|f| &f.id), Some(&id1));
let c = a.costing.get(&id1).expect("id1 is costed");
let expected_m = cfg.coefficients.value * cfg.kind_weight("SL");
assert!(
(c.multiplier - expected_m).abs() < 1e-9,
"m={}",
c.multiplier
);
assert!(c.bare_estimate, "no estimate facet ⇒ bare");
assert!(c.est_cost > 0.0);
}
#[test]
fn build_elicit_inputs_respects_depth_band() {
let (_tmp, root) = mk_project_root();
let id1 = seed_scored_slice(&root, 1);
let _id2 = seed_scored_slice(&root, 2);
let cfg = crate::priority::config::load(&root);
let graph = graph::build(&root).unwrap();
let pipeline = graph::load_comparison_pipeline_for_root(&root).unwrap();
let inputs = build_elicit_inputs(&graph, &pipeline, &cfg, 1);
assert_eq!(inputs.frontier.len(), 1);
assert_eq!(inputs.frontier.first().map(|f| &f.id), Some(&id1));
}
#[test]
fn elicit_over_empty_ledger_is_read_only_and_ok() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "IMP", 1, "open");
run_elicit(&elicit_args(&root)).unwrap();
assert!(
session_files(&root).is_empty(),
"elicit is read-only — no session file minted"
);
}
#[test]
fn bound_json_carries_open_closed_unbounded() {
assert_eq!(
bound_json(comparison::Bound::Open(2.5)),
serde_json::json!({ "kind": "open", "value": 2.5 })
);
assert_eq!(
bound_json(comparison::Bound::Closed(5.0)),
serde_json::json!({ "kind": "closed", "value": 5.0 })
);
assert_eq!(
bound_json(comparison::Bound::Unbounded),
serde_json::json!({ "kind": "unbounded" })
);
}
#[test]
fn value_block_json_shapes_projected_and_classless() {
let projected = value_block_json(
"projected",
2.6,
Some(comparison::ValueBounds {
lower: comparison::Bound::Open(2.5),
upper: comparison::Bound::Open(2.8),
}),
);
assert_eq!(
projected,
serde_json::json!({
"provenance": "projected",
"point": 2.6,
"bounds": {
"lower": { "kind": "open", "value": 2.5 },
"upper": { "kind": "open", "value": 2.8 },
},
})
);
let classless = value_block_json("authored", 4.0, None);
assert_eq!(
classless,
serde_json::json!({ "provenance": "authored", "point": 4.0 })
);
assert!(
classless.get("bounds").is_none(),
"no compiled class ⇒ no bounds key"
);
}
#[test]
fn anchor_exits_json_keys_by_answer_token() {
let subject = crate::priority::elicit::AnchorSubject {
id: "IMP-274".to_string(),
anchor: Some(5.0),
conflict_pairs: vec![("IMP-198".to_string(), "IMP-274".to_string())],
quarantined_rows: vec!["uid-1".to_string(), "uid-2".to_string()],
};
assert_eq!(
anchor_exits_json(&subject),
serde_json::json!({
"revise-anchor": ["doctrine value set IMP-274 <v>"],
"uphold-anchor": [
"doctrine compare record <a> <b> <response> --supersedes uid-1",
"doctrine compare withdraw uid-1",
"doctrine compare withdraw uid-2",
],
})
);
}
#[test]
fn anchor_exits_json_empty_closure_has_no_uphold_actions() {
let subject = crate::priority::elicit::AnchorSubject {
id: "IMP-9".to_string(),
anchor: None,
conflict_pairs: vec![],
quarantined_rows: vec![],
};
assert_eq!(
anchor_exits_json(&subject),
serde_json::json!({
"revise-anchor": ["doctrine value set IMP-9 <v>"],
"uphold-anchor": [],
})
);
}
#[test]
fn comparison_ask_json_carries_frame_and_no_anchor_fields() {
let ask = crate::priority::elicit::AskSpec {
answers: vec!["prefer-a", "prefer-b", "equal", "incomparable"],
yield_by_answer: [("prefer-a".to_string(), 3)].into_iter().collect(),
yield_note: None,
};
let v = comparison_ask_json(&ask);
assert_eq!(
v.get("frame").and_then(|f| f.as_str()),
Some("equal-effort")
);
assert_eq!(v.get("domain").and_then(|d| d.as_str()), Some("value"));
assert!(
v.get("yield_note").is_none(),
"comparison ask omits yield_note"
);
assert!(v.get("exits").is_none(), "comparison ask omits exits");
}
fn empty_queue(state: QueueState, excluded: usize) -> ElicitQueue {
ElicitQueue {
state,
entries: vec![],
excluded_value_insensitive: excluded,
}
}
#[test]
fn state_footer_names_depth_disclaims_and_scopes_m0() {
let (cand_tok, cand) = state_footer(&empty_queue(QueueState::Candidates, 0));
assert_eq!(cand_tok, "candidates");
assert!(cand.contains("candidates outstanding"));
let (stall_tok, stall) = state_footer(&empty_queue(QueueState::Stalled { depth: 8 }, 0));
assert_eq!(stall_tok, "stalled");
assert!(
stall.contains("depth 8") && stall.contains("NOT a stability claim"),
"stall names depth + disclaims: {stall}"
);
let (stable_tok, stable) = state_footer(&empty_queue(QueueState::Stable { depth: 5 }, 0));
assert_eq!(stable_tok, "stable");
assert!(
stable.contains("top-5 frontier members") && stable.contains("not membership"),
"stable is member-scoped: {stable}"
);
assert!(
!stable.contains("value-insensitive"),
"no m=0 clause when none excluded"
);
let (_, scoped) = state_footer(&empty_queue(QueueState::Stable { depth: 5 }, 3));
assert!(
scoped.contains("3 pair(s) value-insensitive (zero weight), outside the claim"),
"m=0 exclusions scope + disclose: {scoped}"
);
}
fn bare_participant(id: &str) -> Participant {
Participant {
id: id.to_string(),
annotations: vec![],
}
}
fn empty_ask() -> crate::priority::elicit::AskSpec {
crate::priority::elicit::AskSpec {
answers: vec![],
yield_by_answer: std::collections::BTreeMap::new(),
yield_note: None,
}
}
fn spine(payload: EntryPayload, kind: CandidateKind) -> QueueEntry {
QueueEntry {
kind,
guaranteed_yield: 1,
guaranteed_impact: 0.5,
score: 0.5,
yield_basis: crate::priority::elicit::YieldBasis::OrderBearingAnswers,
reasons: vec![],
payload,
}
}
#[test]
fn answer_command_per_kind() {
let cmp = spine(
EntryPayload::Comparison {
a: bare_participant("IMP-1"),
b: bare_participant("IMP-2"),
ask: empty_ask(),
},
CandidateKind::Comparison,
);
assert_eq!(
answer_command(&cmp),
"doctrine compare record IMP-1 IMP-2 --prefer a \
(| --prefer b | --equal | --incomparable)"
);
let anchor = spine(
EntryPayload::AnchorReview {
subject: crate::priority::elicit::AnchorSubject {
id: "IMP-3".to_string(),
anchor: Some(5.0),
conflict_pairs: vec![],
quarantined_rows: vec![],
},
ask: empty_ask(),
},
CandidateKind::AnchorReview,
);
assert!(
answer_command(&anchor).starts_with("doctrine value set IMP-3 <v>"),
"anchor revise action: {}",
answer_command(&anchor)
);
}
}