use serde::{Deserialize, Serialize};
use crate::kinds;
use crate::value;
pub(crate) const COMPARISON_SCHEMA: &str = "doctrine.comparison-session";
pub(crate) const COMPARISONS_DIR: &str = "comparisons";
pub(crate) const DOMAIN_VALUE: &str = "value";
pub(crate) const DOMAIN_PRIORITY: &str = "priority";
pub(crate) const DOMAIN_ESTIMATE: &str = "estimate";
pub(crate) const FRAME_EQUAL_EFFORT: &str = "equal-effort";
pub(crate) const FRAME_PREFER_FIRST: &str = "prefer-first";
pub(crate) const FRAME_MORE_WORK: &str = "more-work";
pub(crate) const FRAME_VALUE_ANCHOR: &str = "value-anchor";
pub(crate) const FRAME_COST_ANCHOR: &str = "cost-anchor";
pub(crate) const DOMAIN_FRAMES: &[(&str, &[&str])] = &[
(DOMAIN_VALUE, &[FRAME_EQUAL_EFFORT, FRAME_VALUE_ANCHOR]),
(DOMAIN_PRIORITY, &[FRAME_PREFER_FIRST]),
(DOMAIN_ESTIMATE, &[FRAME_MORE_WORK, FRAME_COST_ANCHOR]),
];
const ANCHOR_FRAMES: &[&str] = &[FRAME_VALUE_ANCHOR, FRAME_COST_ANCHOR];
pub(crate) const COMPARISON_VERSION: u32 = 3;
pub(crate) const SUPPORTED_VERSIONS: &[u32] = &[2, 3];
pub(crate) fn domain_for_frame(frame: &str) -> Option<&'static str> {
DOMAIN_FRAMES
.iter()
.find(|(_, frames)| frames.contains(&frame))
.map(|(domain, _)| *domain)
}
fn frames_for_domain(domain: &str) -> Option<&'static [&'static str]> {
DOMAIN_FRAMES
.iter()
.find(|(d, _)| *d == domain)
.map(|(_, frames)| *frames)
}
fn anchor_frame_for(domain: &str) -> Option<&'static str> {
frames_for_domain(domain)?
.iter()
.copied()
.find(|frame| ANCHOR_FRAMES.contains(frame))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum RaterKind {
Human,
Agent,
Migrated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum RowForm {
Order,
Ratio,
Anchor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum AdmissionKind {
Pin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Response {
PreferA,
PreferB,
Equal,
Incomparable,
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SessionHeader {
pub uid: String,
pub date: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Judgement {
pub uid: String,
pub seq: u32,
pub a: String,
pub b: Option<String>,
pub response: Option<Response>,
pub domain: String,
pub frame: String,
pub form: RowForm,
pub magnitude: Option<f64>,
pub est_lower: Option<f64>,
pub est_upper: Option<f64>,
pub supersedes: Option<String>,
pub lens: Option<String>,
pub rater: RaterKind,
pub by: Option<String>,
pub note: Option<String>,
pub date: Option<String>,
pub observed_at: Option<String>,
pub basis: Option<String>,
pub admission: Option<AdmissionKind>,
}
impl Judgement {
pub(crate) fn ordering_date(&self) -> &str {
self.date
.as_deref()
.or(self.observed_at.as_deref())
.unwrap_or_default()
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Tombstone {
pub uid: String,
pub seq: u32,
pub target: String,
pub date: String,
pub note: Option<String>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct ComparisonSession {
pub schema: String,
pub version: u32,
pub session: SessionHeader,
#[serde(default, rename = "judgement")]
pub judgements: Vec<Judgement>,
#[serde(default, rename = "tombstone")]
pub tombstones: Vec<Tombstone>,
}
pub(crate) fn parse(text: &str) -> anyhow::Result<ComparisonSession> {
let s: ComparisonSession = toml::from_str(text)?;
if s.schema != COMPARISON_SCHEMA {
anyhow::bail!(
"unrecognized comparison-session schema `{}` (expected `{COMPARISON_SCHEMA}`)",
s.schema
);
}
if !SUPPORTED_VERSIONS.contains(&s.version) {
let supported: Vec<String> = SUPPORTED_VERSIONS.iter().map(u32::to_string).collect();
anyhow::bail!(
"unsupported comparison-session version {} (expected one of: {}) — \
schema version 1 was never released; delete or recreate this session file",
s.version,
supported.join(", ")
);
}
Ok(s)
}
pub(crate) fn to_toml(s: &ComparisonSession) -> anyhow::Result<String> {
Ok(toml::to_string(s)?)
}
pub(crate) fn session_of_one(session: SessionHeader, judgement: Judgement) -> ComparisonSession {
ComparisonSession {
schema: COMPARISON_SCHEMA.to_string(),
version: COMPARISON_VERSION,
session,
judgements: vec![judgement],
tombstones: Vec::new(),
}
}
pub(crate) fn validate_judgement(j: &Judgement) -> anyhow::Result<()> {
if j.a.is_empty() {
anyhow::bail!("the subject ref is required — empty ref");
}
let Some(frames) = frames_for_domain(&j.domain) else {
let domains: Vec<&str> = DOMAIN_FRAMES.iter().map(|(d, _)| *d).collect();
anyhow::bail!(
"unknown domain `{}` (expected one of: {})",
j.domain,
domains.join(", ")
);
};
if !frames.contains(&j.frame.as_str()) {
anyhow::bail!(
"frame `{}` is not admissible in domain `{}` (expected one of: {})",
j.frame,
j.domain,
frames.join(", ")
);
}
validate_form(j)?;
validate_provenance(j)
}
fn validate_form(j: &Judgement) -> anyhow::Result<()> {
let anchor_frame = anchor_frame_for(&j.domain);
match j.form {
RowForm::Anchor => {
if anchor_frame != Some(j.frame.as_str()) {
let expected = anchor_frame.map_or_else(
|| format!(" (domain `{}` has none)", j.domain),
|f| format!(" (`{f}`)"),
);
anyhow::bail!(
"form `anchor` requires the domain's anchor frame{expected} — got `{}`",
j.frame
);
}
if j.b.is_some() {
anyhow::bail!("an anchor row claims a single subject — `b` must be absent");
}
if j.response.is_some() {
anyhow::bail!(
"an anchor row carries no pairwise response — `response` must be absent"
);
}
validate_anchor_payload(j)
}
RowForm::Order | RowForm::Ratio => {
if anchor_frame == Some(j.frame.as_str()) {
anyhow::bail!(
"frame `{}` is the `{}` domain's anchor frame — pairwise rows use a \
pairwise frame",
j.frame,
j.domain
);
}
if j.est_lower.is_some() || j.est_upper.is_some() {
anyhow::bail!(
"a pairwise row carries no estimate payload — `est_lower`/`est_upper` \
must be absent"
);
}
let Some(b) = j.b.as_deref() else {
anyhow::bail!("both sides of the pair are required — `b` is absent");
};
if b.is_empty() {
anyhow::bail!("both sides of the pair are required — empty ref");
}
if j.a == b {
anyhow::bail!("cannot compare `{}` against itself", j.a);
}
if j.response.is_none() {
anyhow::bail!("a pairwise row requires a `response`");
}
Ok(())
}
}
}
fn validate_anchor_payload(j: &Judgement) -> anyhow::Result<()> {
match j.domain.as_str() {
DOMAIN_VALUE => {
if j.est_lower.is_some() || j.est_upper.is_some() {
anyhow::bail!(
"a value anchor carries no estimate payload — `est_lower`/`est_upper` \
must be absent"
);
}
let Some(magnitude) = j.magnitude else {
anyhow::bail!("a value anchor claims a magnitude — `magnitude` is required");
};
value::validate(&value::ValueFacet { value: magnitude })
}
DOMAIN_ESTIMATE => {
if j.magnitude.is_some() {
anyhow::bail!("a cost anchor carries no magnitude — `magnitude` must be absent");
}
let Some(lower) = j.est_lower else {
anyhow::bail!("a cost anchor claims a lower bound — `est_lower` is required");
};
let Some(upper) = j.est_upper else {
anyhow::bail!("a cost anchor claims an upper bound — `est_upper` is required");
};
let facet = crate::estimate::EstimateFacet { lower, upper };
crate::estimate::validate(&facet)
}
_ => {
anyhow::bail!(
"anchor payload validation not implemented for domain `{}`",
j.domain
);
}
}
}
fn validate_provenance(j: &Judgement) -> anyhow::Result<()> {
match j.rater {
RaterKind::Migrated => {
if !matches!(j.form, RowForm::Anchor) {
anyhow::bail!("rater `migrated` is facet-import provenance — anchor rows only");
}
if j.date.is_some() {
anyhow::bail!(
"a migrated row's authorship date is unknown — `date` must be absent \
(`observed_at` carries the migration date)"
);
}
if j.observed_at.is_none() {
anyhow::bail!(
"a migrated row records its migration date — `observed_at` is required"
);
}
}
RaterKind::Human | RaterKind::Agent => {
if j.date.is_none() {
anyhow::bail!("`date` is required (absent only on `rater = migrated` rows)");
}
if j.observed_at.is_some() {
anyhow::bail!(
"`observed_at` is migration provenance — `rater = migrated` rows only"
);
}
}
}
if j.admission.is_some()
&& !(matches!(j.form, RowForm::Anchor) && matches!(j.rater, RaterKind::Human))
{
anyhow::bail!(
"`admission = \"pin\"` requires a human anchor row — contradictory provenance"
);
}
Ok(())
}
pub(crate) fn admissible_value_pair(kind_a: &str, kind_b: &str) -> Result<(), String> {
admissible_value_kind(kind_a)?;
admissible_value_kind(kind_b)
}
pub(crate) fn admissible_estimate_pair(kind_a: &str, kind_b: &str) -> Result<(), String> {
admissible_estimate_kind(kind_a)?;
admissible_estimate_kind(kind_b)
}
fn admissible_estimate_kind(kind: &str) -> Result<(), String> {
if kinds::VALUE_BEARING.contains(&kind) || kinds::RECORD.contains(&kind) {
return Ok(());
}
Err(format!(
"{kind} has no comparable settle-cost — estimate comparison admits work and record kinds"
))
}
fn admissible_value_kind(kind: &str) -> Result<(), String> {
if kind == kinds::RSK {
return Err(format!(
"{kind} is excluded from value comparison — risk carries exposure, not value"
));
}
if !kinds::VALUE_BEARING.contains(&kind) {
return Err(format!(
"{kind} is not value-bearing — value comparison admits value-bearing kinds only"
));
}
Ok(())
}
pub(crate) fn validate_anchor_supersedes(
new: &Judgement,
target: &Judgement,
) -> Result<(), String> {
if !matches!(target.form, RowForm::Anchor) {
return Err(format!(
"--supersedes target `{}` is a pairwise row — an anchor claim supersedes only another anchor claim",
target.uid
));
}
if new.a != target.a {
return Err(format!(
"--supersedes target `{}` claims `{}`, not `{}` — supersession stays within one subject",
target.uid, target.a, new.a
));
}
if new.domain != target.domain {
return Err(format!(
"--supersedes target `{}` is domain `{}`, not `{}` — cross-domain supersession is refused",
target.uid, target.domain, new.domain
));
}
if new.lens != target.lens {
let show = |l: &Option<String>| l.clone().unwrap_or_else(|| "(none)".to_string());
return Err(format!(
"--supersedes target `{}` lens `{}` differs from `{}` — cross-lens supersession is refused",
target.uid,
show(&target.lens),
show(&new.lens)
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn full_session() -> ComparisonSession {
ComparisonSession {
schema: COMPARISON_SCHEMA.to_string(),
version: 2,
session: SessionHeader {
uid: "0197f3a2-5b1e-7c3d-9e4f-1a2b3c4d5e6f".to_string(),
date: "2026-07-10".to_string(),
audience: Some("stakeholder".to_string()),
},
judgements: vec![full_judgement()],
tombstones: vec![Tombstone {
uid: "0197f3a4-7d30-7e5f-9a6b-3c4d5e6f7a8b".to_string(),
seq: 1,
target: "0197f3a2-6c2f-7d4e-8f5a-2b3c4d5e6f7a".to_string(),
date: "2026-07-10".to_string(),
note: Some("wrong way round".to_string()),
}],
}
}
fn full_judgement() -> Judgement {
Judgement {
uid: "0197f3a2-6c2f-7d4e-8f5a-2b3c4d5e6f7a".to_string(),
seq: 0,
a: "SL-204".to_string(),
b: Some("IMP-118".to_string()),
response: Some(Response::PreferA),
domain: DOMAIN_VALUE.to_string(),
frame: FRAME_EQUAL_EFFORT.to_string(),
form: RowForm::Order,
magnitude: Some(2.5),
est_lower: None,
est_upper: None,
supersedes: Some("0197f3a1-1111-7abc-8def-0a1b2c3d4e5f".to_string()),
lens: Some("user-value".to_string()),
rater: RaterKind::Agent,
by: Some("david".to_string()),
note: Some("auth unblocks the pilot".to_string()),
date: Some("2026-07-10".to_string()),
observed_at: None,
basis: None,
admission: None,
}
}
fn estimate_judgement() -> Judgement {
Judgement {
uid: "0197f3a2-8f52-7a71-9c8d-5e6f7a8b9c0d".to_string(),
seq: 2,
a: "SL-204".to_string(),
b: Some("RSK-004".to_string()),
response: Some(Response::PreferB),
domain: DOMAIN_ESTIMATE.to_string(),
frame: FRAME_MORE_WORK.to_string(),
form: RowForm::Order,
magnitude: None,
est_lower: None,
est_upper: None,
supersedes: None,
lens: None,
rater: RaterKind::Human,
by: None,
note: None,
date: Some("2026-07-10".to_string()),
observed_at: None,
basis: None,
admission: None,
}
}
fn bare_judgement() -> Judgement {
Judgement {
uid: "0197f3a2-7e41-7f60-8b7c-4d5e6f7a8b9c".to_string(),
seq: 1,
a: "IMP-118".to_string(),
b: Some("CHR-042".to_string()),
response: Some(Response::PreferB),
domain: DOMAIN_PRIORITY.to_string(),
frame: FRAME_PREFER_FIRST.to_string(),
form: RowForm::Order,
magnitude: None,
est_lower: None,
est_upper: None,
supersedes: None,
lens: None,
rater: RaterKind::Human,
by: None,
note: None,
date: Some("2026-07-10".to_string()),
observed_at: None,
basis: None,
admission: None,
}
}
fn human_anchor() -> Judgement {
Judgement {
uid: "0197f3a2-9a63-7b82-8d9e-6f7a8b9c0d1e".to_string(),
seq: 0,
a: "SL-204".to_string(),
b: None,
response: None,
domain: DOMAIN_VALUE.to_string(),
frame: FRAME_VALUE_ANCHOR.to_string(),
form: RowForm::Anchor,
magnitude: Some(6.5),
est_lower: None,
est_upper: None,
supersedes: None,
lens: None,
rater: RaterKind::Human,
by: Some("david".to_string()),
note: None,
date: Some("2026-07-16".to_string()),
observed_at: None,
basis: Some("REQ-059".to_string()),
admission: None,
}
}
fn pin_anchor() -> Judgement {
Judgement {
admission: Some(AdmissionKind::Pin),
..human_anchor()
}
}
fn human_estimate_anchor() -> Judgement {
Judgement {
uid: "0197f3a2-bc85-7da4-9fb0-8b9c0d1e2f3a".to_string(),
seq: 0,
a: "SL-221".to_string(),
b: None,
response: None,
domain: DOMAIN_ESTIMATE.to_string(),
frame: FRAME_COST_ANCHOR.to_string(),
form: RowForm::Anchor,
magnitude: None,
est_lower: Some(2.0),
est_upper: Some(8.0),
supersedes: None,
lens: None,
rater: RaterKind::Human,
by: Some("david".to_string()),
note: None,
date: Some("2026-07-17".to_string()),
observed_at: None,
basis: Some("ASM-014; assumes v3 wire reuse".to_string()),
admission: None,
}
}
fn pin_estimate_anchor() -> Judgement {
Judgement {
admission: Some(AdmissionKind::Pin),
..human_estimate_anchor()
}
}
fn migrated_estimate_anchor() -> Judgement {
Judgement {
uid: "0197f3a2-cd96-7eb5-9fc1-9b0c1d2e3f4b".to_string(),
seq: 1,
a: "IMP-118".to_string(),
b: None,
response: None,
domain: DOMAIN_ESTIMATE.to_string(),
frame: FRAME_COST_ANCHOR.to_string(),
form: RowForm::Anchor,
magnitude: None,
est_lower: Some(1.0),
est_upper: Some(3.0),
supersedes: None,
lens: None,
rater: RaterKind::Migrated,
by: None,
note: None,
date: None,
observed_at: Some("2026-07-17".to_string()),
basis: Some(
"facet [estimate] .doctrine/backlog/imp-118.toml @ 9c01d2aa david 2026-06-28"
.to_string(),
),
admission: None,
}
}
fn point_estimate_anchor() -> Judgement {
Judgement {
uid: "0197f3a2-dea7-7fc6-9fd2-ac1d2e3f5c6d".to_string(),
seq: 2,
a: "PLAN-001".to_string(),
b: None,
response: None,
domain: DOMAIN_ESTIMATE.to_string(),
frame: FRAME_COST_ANCHOR.to_string(),
form: RowForm::Anchor,
magnitude: None,
est_lower: Some(5.0),
est_upper: Some(5.0),
supersedes: None,
lens: None,
rater: RaterKind::Human,
by: None,
note: None,
date: Some("2026-07-17".to_string()),
observed_at: None,
basis: None,
admission: None,
}
}
fn migrated_anchor() -> Judgement {
Judgement {
uid: "0197f3a2-ab74-7c93-9eaf-7a8b9c0d1e2f".to_string(),
seq: 1,
a: "IMP-118".to_string(),
b: None,
response: None,
domain: DOMAIN_VALUE.to_string(),
frame: FRAME_VALUE_ANCHOR.to_string(),
form: RowForm::Anchor,
magnitude: Some(3.0),
est_lower: None,
est_upper: None,
supersedes: None,
lens: None,
rater: RaterKind::Migrated,
by: None,
note: None,
date: None,
observed_at: Some("2026-07-16".to_string()),
basis: Some("facet [value] .doctrine/backlog/imp-118.toml @ 4a12e576".to_string()),
admission: None,
}
}
#[test]
fn vocab_matches_documented_schema() {
assert_eq!(COMPARISON_SCHEMA, "doctrine.comparison-session");
assert_eq!(COMPARISONS_DIR, "comparisons");
assert_eq!(COMPARISON_VERSION, 3);
assert_eq!(SUPPORTED_VERSIONS, &[2, 3]);
assert_eq!(DOMAIN_VALUE, "value");
assert_eq!(DOMAIN_PRIORITY, "priority");
assert_eq!(DOMAIN_ESTIMATE, "estimate");
assert_eq!(FRAME_MORE_WORK, "more-work");
assert_eq!(FRAME_VALUE_ANCHOR, "value-anchor");
assert_eq!(FRAME_COST_ANCHOR, "cost-anchor");
assert_eq!(
DOMAIN_FRAMES,
&[
("value", &["equal-effort", "value-anchor"][..]),
("priority", &["prefer-first"][..]),
("estimate", &["more-work", "cost-anchor"][..])
]
);
}
#[test]
fn domain_for_frame_derives_from_the_table() {
assert_eq!(domain_for_frame(FRAME_EQUAL_EFFORT), Some(DOMAIN_VALUE));
assert_eq!(domain_for_frame(FRAME_PREFER_FIRST), Some(DOMAIN_PRIORITY));
assert_eq!(domain_for_frame(FRAME_MORE_WORK), Some(DOMAIN_ESTIMATE));
assert_eq!(domain_for_frame(FRAME_VALUE_ANCHOR), Some(DOMAIN_VALUE));
assert_eq!(domain_for_frame(FRAME_COST_ANCHOR), Some(DOMAIN_ESTIMATE));
assert_eq!(domain_for_frame("opportunity-cost"), None);
}
#[test]
fn anchor_frame_for_derives_from_the_membership_set() {
assert_eq!(anchor_frame_for(DOMAIN_VALUE), Some(FRAME_VALUE_ANCHOR));
assert_eq!(anchor_frame_for(DOMAIN_PRIORITY), None);
assert_eq!(anchor_frame_for(DOMAIN_ESTIMATE), Some(FRAME_COST_ANCHOR));
assert_eq!(anchor_frame_for("effort"), None);
}
#[test]
fn golden_shape_matches_documented_schema() {
let expected = "\
schema = \"doctrine.comparison-session\"
version = 2
[session]
uid = \"0197f3a2-5b1e-7c3d-9e4f-1a2b3c4d5e6f\"
date = \"2026-07-10\"
audience = \"stakeholder\"
[[judgement]]
uid = \"0197f3a2-6c2f-7d4e-8f5a-2b3c4d5e6f7a\"
seq = 0
a = \"SL-204\"
b = \"IMP-118\"
response = \"prefer-a\"
domain = \"value\"
frame = \"equal-effort\"
form = \"order\"
magnitude = 2.5
supersedes = \"0197f3a1-1111-7abc-8def-0a1b2c3d4e5f\"
lens = \"user-value\"
rater = \"agent\"
by = \"david\"
note = \"auth unblocks the pilot\"
date = \"2026-07-10\"
[[tombstone]]
uid = \"0197f3a4-7d30-7e5f-9a6b-3c4d5e6f7a8b\"
seq = 1
target = \"0197f3a2-6c2f-7d4e-8f5a-2b3c4d5e6f7a\"
date = \"2026-07-10\"
note = \"wrong way round\"
";
assert_eq!(to_toml(&full_session()).unwrap(), expected);
}
#[test]
fn response_vocabulary_round_trips() {
let cases = [
(Response::PreferA, "prefer-a"),
(Response::PreferB, "prefer-b"),
(Response::Equal, "equal"),
(Response::Incomparable, "incomparable"),
];
for (response, token) in cases {
let mut s = full_session();
s.judgements[0].response = Some(response);
let text = to_toml(&s).unwrap();
assert!(
text.contains(&format!("response = \"{token}\"")),
"{token} on the wire:\n{text}"
);
assert_eq!(parse(&text).unwrap().judgements[0].response, Some(response));
}
}
#[test]
fn parse_rejects_unknown_response_token() {
let text = to_toml(&full_session())
.unwrap()
.replace("response = \"prefer-a\"", "response = \"prefer-c\"");
assert!(parse(&text).is_err());
}
#[test]
fn round_trip_preserves_all_fields() {
let mut s = full_session();
s.judgements.push(bare_judgement());
let text = to_toml(&s).unwrap();
assert_eq!(parse(&text).unwrap(), s);
}
#[test]
fn parse_preserves_unknown_frame_rows() {
let mut s = full_session();
s.judgements[0].frame = "opportunity-cost".to_string();
let text = to_toml(&s).unwrap();
let parsed = parse(&text).unwrap();
assert_eq!(parsed.judgements[0].frame, "opportunity-cost");
assert_eq!(parsed, s);
}
#[test]
fn parse_rejects_wrong_schema() {
let mut s = full_session();
s.schema = "doctrine.plan".to_string();
let text = to_toml(&s).unwrap();
let err = parse(&text).unwrap_err().to_string();
assert!(
err.contains(COMPARISON_SCHEMA),
"err names expected schema: {err}"
);
}
#[test]
fn parse_rejects_version_1_naming_remedy() {
let mut s = full_session();
s.version = 1;
let text = to_toml(&s).unwrap();
let err = parse(&text).unwrap_err().to_string();
assert!(err.contains("never released"), "remedy named: {err}");
assert!(err.contains("delete or recreate"), "remedy named: {err}");
}
#[test]
fn parse_rejects_any_unsupported_version() {
let mut s = full_session();
s.version = 4;
let text = to_toml(&s).unwrap();
let err = parse(&text).unwrap_err().to_string();
assert!(err.contains("version 4"), "err names the version: {err}");
assert!(err.contains("2, 3"), "err names the supported set: {err}");
}
#[test]
fn parse_accepts_v2_and_v3() {
for version in [2, 3] {
let mut s = full_session();
s.version = version;
let text = to_toml(&s).unwrap();
assert_eq!(parse(&text).unwrap().version, version);
}
}
#[test]
fn session_of_one_stamps_version_3() {
let s = session_of_one(full_session().session, human_anchor());
assert_eq!(s.version, 3);
assert!(to_toml(&s).unwrap().contains("version = 3\n"));
}
#[test]
fn validate_accepts_value_equal_effort() {
assert!(validate_judgement(&full_judgement()).is_ok());
}
#[test]
fn validate_accepts_priority_prefer_first() {
assert!(validate_judgement(&bare_judgement()).is_ok());
}
#[test]
fn validate_accepts_estimate_more_work() {
assert!(validate_judgement(&estimate_judgement()).is_ok());
}
#[test]
fn validate_rejects_more_work_outside_estimate_domain() {
let mut j = full_judgement();
j.frame = FRAME_MORE_WORK.to_string(); let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("not admissible"), "got: {err}");
let mut j = estimate_judgement();
j.frame = FRAME_EQUAL_EFFORT.to_string(); let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("not admissible"), "got: {err}");
}
#[test]
fn round_trip_preserves_estimate_domain_rows() {
let mut s = full_session();
s.judgements.push(estimate_judgement());
let text = to_toml(&s).unwrap();
assert!(
text.contains(&format!("domain = \"{DOMAIN_ESTIMATE}\"")),
"estimate domain on the wire:\n{text}"
);
assert!(
text.contains(&format!("frame = \"{FRAME_MORE_WORK}\"")),
"more-work frame on the wire:\n{text}"
);
assert_eq!(parse(&text).unwrap(), s);
}
#[test]
fn validate_rejects_cross_domain_frames() {
let mut j = full_judgement();
j.frame = FRAME_PREFER_FIRST.to_string(); let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("not admissible"), "got: {err}");
let mut j = bare_judgement();
j.frame = FRAME_EQUAL_EFFORT.to_string(); let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("not admissible"), "got: {err}");
}
#[test]
fn validate_rejects_self_pair() {
let mut j = full_judgement();
j.b = Some(j.a.clone());
assert!(validate_judgement(&j).is_err());
}
#[test]
fn validate_rejects_unknown_frame() {
let mut j = full_judgement();
j.frame = "opportunity-cost".to_string();
assert!(validate_judgement(&j).is_err());
}
#[test]
fn validate_rejects_unknown_domain() {
let mut j = full_judgement();
j.domain = "effort".to_string();
assert!(validate_judgement(&j).is_err());
}
#[test]
fn validate_rejects_empty_refs() {
let mut j = full_judgement();
j.a = String::new();
assert!(validate_judgement(&j).is_err());
let mut j = full_judgement();
j.b = Some(String::new());
assert!(validate_judgement(&j).is_err());
}
#[test]
fn golden_shape_v3_anchor_rows() {
let s = ComparisonSession {
schema: COMPARISON_SCHEMA.to_string(),
version: COMPARISON_VERSION,
session: SessionHeader {
uid: "0197f3a2-5b1e-7c3d-9e4f-1a2b3c4d5e6f".to_string(),
date: "2026-07-16".to_string(),
audience: None,
},
judgements: vec![human_anchor(), migrated_anchor(), pin_anchor()],
tombstones: vec![],
};
let expected = "\
schema = \"doctrine.comparison-session\"
version = 3
tombstone = []
[session]
uid = \"0197f3a2-5b1e-7c3d-9e4f-1a2b3c4d5e6f\"
date = \"2026-07-16\"
[[judgement]]
uid = \"0197f3a2-9a63-7b82-8d9e-6f7a8b9c0d1e\"
seq = 0
a = \"SL-204\"
domain = \"value\"
frame = \"value-anchor\"
form = \"anchor\"
magnitude = 6.5
rater = \"human\"
by = \"david\"
date = \"2026-07-16\"
basis = \"REQ-059\"
[[judgement]]
uid = \"0197f3a2-ab74-7c93-9eaf-7a8b9c0d1e2f\"
seq = 1
a = \"IMP-118\"
domain = \"value\"
frame = \"value-anchor\"
form = \"anchor\"
magnitude = 3.0
rater = \"migrated\"
observed_at = \"2026-07-16\"
basis = \"facet [value] .doctrine/backlog/imp-118.toml @ 4a12e576\"
[[judgement]]
uid = \"0197f3a2-9a63-7b82-8d9e-6f7a8b9c0d1e\"
seq = 0
a = \"SL-204\"
domain = \"value\"
frame = \"value-anchor\"
form = \"anchor\"
magnitude = 6.5
rater = \"human\"
by = \"david\"
date = \"2026-07-16\"
basis = \"REQ-059\"
admission = \"pin\"
";
assert_eq!(to_toml(&s).unwrap(), expected);
}
#[test]
fn round_trip_preserves_anchor_rows_and_new_optionals() {
let mut s = full_session();
s.judgements.push(bare_judgement());
s.judgements.push(human_anchor());
s.judgements.push(pin_anchor());
s.judgements.push(migrated_anchor());
let text = to_toml(&s).unwrap();
assert_eq!(parse(&text).unwrap(), s);
}
#[test]
fn parse_rejects_unknown_admission_token() {
let mut s = full_session();
s.judgements = vec![pin_anchor()];
let text = to_toml(&s)
.unwrap()
.replace("admission = \"pin\"", "admission = \"vouched\"");
assert!(parse(&text).is_err());
}
#[test]
fn parse_rejects_unknown_rater_token() {
let mut s = full_session();
s.judgements = vec![migrated_anchor()];
let text = to_toml(&s)
.unwrap()
.replace("rater = \"migrated\"", "rater = \"imported\"");
assert!(parse(&text).is_err());
}
#[test]
fn validate_accepts_live_human_anchor() {
assert!(validate_judgement(&human_anchor()).is_ok());
}
#[test]
fn validate_accepts_pin() {
assert!(validate_judgement(&pin_anchor()).is_ok());
}
#[test]
fn validate_accepts_migrated_anchor() {
assert!(validate_judgement(&migrated_anchor()).is_ok());
}
#[test]
fn validate_rejects_anchor_with_b() {
let mut j = human_anchor();
j.b = Some("IMP-118".to_string());
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("single subject"), "got: {err}");
}
#[test]
fn validate_rejects_anchor_with_response() {
let mut j = human_anchor();
j.response = Some(Response::PreferA);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("no pairwise response"), "got: {err}");
}
#[test]
fn validate_rejects_anchor_without_magnitude() {
let mut j = human_anchor();
j.magnitude = None;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("magnitude"), "got: {err}");
}
#[test]
fn validate_anchor_magnitude_mirrors_value_validate() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut j = human_anchor();
j.magnitude = Some(bad);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(
err.contains("finite"),
"{bad} rejected naming finite: {err}"
);
}
let mut j = human_anchor();
j.magnitude = Some(-2.5);
assert!(validate_judgement(&j).is_ok(), "negatives are admissible");
}
#[test]
fn validate_rejects_anchor_with_pairwise_frame() {
let mut j = human_anchor();
j.frame = FRAME_EQUAL_EFFORT.to_string();
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("anchor frame"), "got: {err}");
}
#[test]
fn validate_rejects_order_row_with_anchor_frame() {
for form in [RowForm::Order, RowForm::Ratio] {
let mut j = full_judgement();
j.frame = FRAME_VALUE_ANCHOR.to_string();
j.form = form;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("pairwise"), "got: {err}");
}
}
#[test]
fn validate_rejects_anchor_in_domain_without_anchor_frame() {
let mut j = human_anchor();
j.domain = DOMAIN_PRIORITY.to_string();
j.frame = FRAME_PREFER_FIRST.to_string();
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("has none"), "got: {err}");
}
#[test]
fn validate_rejects_pairwise_row_missing_b_or_response() {
let mut j = full_judgement();
j.b = None;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("`b` is absent"), "got: {err}");
let mut j = full_judgement();
j.response = None;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("requires a `response`"), "got: {err}");
}
#[test]
fn validate_rejects_migrated_on_pairwise_row() {
let mut j = full_judgement();
j.rater = RaterKind::Migrated;
j.date = None;
j.observed_at = Some("2026-07-16".to_string());
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("anchor rows only"), "got: {err}");
}
#[test]
fn validate_enforces_date_observed_at_biconditional() {
let mut j = migrated_anchor();
j.date = Some("2026-07-16".to_string());
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("`date` must be absent"), "got: {err}");
let mut j = migrated_anchor();
j.observed_at = None;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("`observed_at` is required"), "got: {err}");
let mut j = human_anchor();
j.observed_at = Some("2026-07-16".to_string());
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("migration provenance"), "got: {err}");
let mut j = human_anchor();
j.date = None;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("`date` is required"), "got: {err}");
let mut j = full_judgement();
j.date = None;
assert!(validate_judgement(&j).is_err());
let mut j = full_judgement();
j.observed_at = Some("2026-07-16".to_string());
assert!(validate_judgement(&j).is_err());
}
#[test]
fn validate_rejects_pin_without_human_anchor() {
let mut j = pin_anchor();
j.rater = RaterKind::Agent;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("human anchor"), "got: {err}");
let mut j = full_judgement();
j.admission = Some(AdmissionKind::Pin);
j.rater = RaterKind::Human;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("human anchor"), "got: {err}");
}
#[test]
fn validate_rejects_anchor_with_empty_subject() {
let mut j = human_anchor();
j.a = String::new();
assert!(validate_judgement(&j).is_err());
}
#[test]
fn golden_shape_estimate_anchor_rows() {
let s = ComparisonSession {
schema: COMPARISON_SCHEMA.to_string(),
version: COMPARISON_VERSION,
session: SessionHeader {
uid: "0197f3a2-5b1e-7c3d-9e4f-1a2b3c4d5e6f".to_string(),
date: "2026-07-17".to_string(),
audience: None,
},
judgements: vec![
human_estimate_anchor(),
pin_estimate_anchor(),
migrated_estimate_anchor(),
point_estimate_anchor(),
],
tombstones: vec![],
};
let expected = r#"schema = "doctrine.comparison-session"
version = 3
tombstone = []
[session]
uid = "0197f3a2-5b1e-7c3d-9e4f-1a2b3c4d5e6f"
date = "2026-07-17"
[[judgement]]
uid = "0197f3a2-bc85-7da4-9fb0-8b9c0d1e2f3a"
seq = 0
a = "SL-221"
domain = "estimate"
frame = "cost-anchor"
form = "anchor"
est_lower = 2.0
est_upper = 8.0
rater = "human"
by = "david"
date = "2026-07-17"
basis = "ASM-014; assumes v3 wire reuse"
[[judgement]]
uid = "0197f3a2-bc85-7da4-9fb0-8b9c0d1e2f3a"
seq = 0
a = "SL-221"
domain = "estimate"
frame = "cost-anchor"
form = "anchor"
est_lower = 2.0
est_upper = 8.0
rater = "human"
by = "david"
date = "2026-07-17"
basis = "ASM-014; assumes v3 wire reuse"
admission = "pin"
[[judgement]]
uid = "0197f3a2-cd96-7eb5-9fc1-9b0c1d2e3f4b"
seq = 1
a = "IMP-118"
domain = "estimate"
frame = "cost-anchor"
form = "anchor"
est_lower = 1.0
est_upper = 3.0
rater = "migrated"
observed_at = "2026-07-17"
basis = "facet [estimate] .doctrine/backlog/imp-118.toml @ 9c01d2aa david 2026-06-28"
[[judgement]]
uid = "0197f3a2-dea7-7fc6-9fd2-ac1d2e3f5c6d"
seq = 2
a = "PLAN-001"
domain = "estimate"
frame = "cost-anchor"
form = "anchor"
est_lower = 5.0
est_upper = 5.0
rater = "human"
date = "2026-07-17"
"#;
assert_eq!(to_toml(&s).unwrap(), expected);
}
#[test]
fn round_trip_preserves_estimate_anchor_est_lower_est_upper() {
let mut s = full_session();
s.judgements = vec![
full_judgement(),
bare_judgement(),
human_anchor(),
migrated_anchor(),
pin_anchor(),
human_estimate_anchor(),
pin_estimate_anchor(),
migrated_estimate_anchor(),
point_estimate_anchor(),
];
let text = to_toml(&s).unwrap();
let parsed = parse(&text).unwrap();
assert_eq!(parsed, s);
for j in &parsed.judgements {
if j.frame == FRAME_COST_ANCHOR {
assert!(
j.est_lower.is_some(),
"cost-anchor row must carry est_lower"
);
assert!(
j.est_upper.is_some(),
"cost-anchor row must carry est_upper"
);
}
}
}
#[test]
fn validate_accepts_human_estimate_anchor() {
assert!(validate_judgement(&human_estimate_anchor()).is_ok());
}
#[test]
fn validate_accepts_pin_estimate_anchor() {
assert!(validate_judgement(&pin_estimate_anchor()).is_ok());
}
#[test]
fn validate_accepts_migrated_estimate_anchor() {
assert!(validate_judgement(&migrated_estimate_anchor()).is_ok());
}
#[test]
fn validate_accepts_point_estimate_anchor() {
assert!(validate_judgement(&point_estimate_anchor()).is_ok());
}
#[test]
fn validate_rejects_estimate_anchor_without_est_lower() {
let mut j = human_estimate_anchor();
j.est_lower = None;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("est_lower"), "got: {err}");
}
#[test]
fn validate_rejects_estimate_anchor_without_est_upper() {
let mut j = human_estimate_anchor();
j.est_upper = None;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("est_upper"), "got: {err}");
}
#[test]
fn validate_estimate_anchor_est_pair_mirrors_estimate_validate() {
let mut j = human_estimate_anchor();
j.est_lower = Some(f64::NAN);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("finite"), "nan lower: {err}");
let mut j = human_estimate_anchor();
j.est_upper = Some(f64::INFINITY);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("finite"), "inf upper: {err}");
let mut j = human_estimate_anchor();
j.est_lower = Some(-1.0);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains(">= 0"), "negative lower: {err}");
let mut j = human_estimate_anchor();
j.est_lower = Some(8.0);
j.est_upper = Some(2.0);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("upper"), "upper < lower: {err}");
}
#[test]
fn validate_rejects_cost_anchor_on_pairwise_row() {
let mut j = estimate_judgement();
j.frame = FRAME_COST_ANCHOR.to_string();
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(
err.contains("anchor frame") || err.contains("pairwise"),
"got: {err}"
);
}
#[test]
fn validate_rejects_estimate_anchor_with_pairwise_frame() {
let mut j = human_estimate_anchor();
j.frame = FRAME_MORE_WORK.to_string();
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("anchor frame"), "got: {err}");
}
#[test]
fn validate_rejects_pairwise_row_with_est_fields() {
let mut j = full_judgement();
j.est_lower = Some(2.0);
j.est_upper = Some(8.0);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(
err.contains("est_lower") || err.contains("est_upper"),
"extra est fields on pairwise: {err}"
);
}
#[test]
fn validate_rejects_value_anchor_with_est_fields() {
let mut j = human_anchor();
j.est_lower = Some(2.0);
j.est_upper = Some(8.0);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(
err.contains("must be absent"),
"extra estimate fields on value anchor: {err}"
);
}
#[test]
fn validate_rejects_estimate_anchor_with_magnitude() {
let mut j = human_estimate_anchor();
j.magnitude = Some(3.5);
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(
err.contains("magnitude") && err.contains("must be absent"),
"got: {err}"
);
}
#[test]
fn validate_pin_on_estimate_anchor_is_valid() {
assert!(validate_judgement(&pin_estimate_anchor()).is_ok());
}
#[test]
fn validate_rejects_agent_pin_on_estimate_anchor() {
let mut j = pin_estimate_anchor();
j.rater = RaterKind::Agent;
let err = validate_judgement(&j).unwrap_err().to_string();
assert!(err.contains("human anchor"), "got: {err}");
}
#[test]
fn admissible_value_pair_admits_cross_kind_work() {
assert!(admissible_value_pair(kinds::SL, kinds::IMP).is_ok());
}
#[test]
fn admissible_value_pair_refuses_record() {
assert!(admissible_value_pair(kinds::QUE, kinds::SL).is_err());
}
#[test]
fn admissible_value_pair_refuses_rsk() {
assert!(admissible_value_pair(kinds::SL, kinds::RSK).is_err());
}
#[test]
fn admit_set_is_value_bearing_minus_rsk() {
for &kind in kinds::ALL_KINDS {
let admitted = admissible_value_pair(kind, kind).is_ok();
let expected = kinds::VALUE_BEARING.contains(&kind) && kind != kinds::RSK;
assert_eq!(
admitted, expected,
"{kind}: admitted iff value-bearing minus RSK"
);
}
}
#[test]
fn admissible_estimate_pair_admits_rsk_and_records() {
assert!(admissible_estimate_pair(kinds::SL, kinds::RSK).is_ok());
assert!(admissible_estimate_pair(kinds::QUE, kinds::SL).is_ok());
assert!(admissible_estimate_pair(kinds::QUE, kinds::CON).is_ok());
}
#[test]
fn estimate_admit_set_is_value_bearing_union_record() {
for &kind in kinds::ALL_KINDS {
let admitted = admissible_estimate_pair(kind, kind).is_ok();
let expected = kinds::VALUE_BEARING.contains(&kind) || kinds::RECORD.contains(&kind);
assert_eq!(
admitted, expected,
"{kind}: admitted iff value-bearing or record"
);
}
}
#[test]
fn validate_anchor_supersedes_accepts_same_scope() {
let target = human_anchor();
let new = human_anchor();
assert!(validate_anchor_supersedes(&new, &target).is_ok());
}
#[test]
fn validate_anchor_supersedes_refuses_pairwise_target() {
let err = validate_anchor_supersedes(&human_anchor(), &full_judgement()).unwrap_err();
assert!(err.contains("pairwise row"), "got: {err}");
}
#[test]
fn validate_anchor_supersedes_refuses_foreign_subject() {
let mut target = human_anchor();
target.a = "IMP-118".to_string();
let err = validate_anchor_supersedes(&human_anchor(), &target).unwrap_err();
assert!(err.contains("within one subject"), "got: {err}");
}
#[test]
fn validate_anchor_supersedes_refuses_cross_lens() {
let mut target = human_anchor();
target.lens = Some("user-value".to_string());
let err = validate_anchor_supersedes(&human_anchor(), &target).unwrap_err();
assert!(err.contains("cross-lens"), "got: {err}");
}
#[test]
fn validate_anchor_supersedes_refuses_cross_domain() {
let mut target = human_anchor();
target.domain = DOMAIN_PRIORITY.to_string();
let err = validate_anchor_supersedes(&human_anchor(), &target).unwrap_err();
assert!(err.contains("cross-domain"), "got: {err}");
}
#[test]
fn admissible_estimate_pair_refusal_names_the_currency() {
let err = admissible_estimate_pair(kinds::REV, kinds::SL).unwrap_err();
assert!(err.contains(kinds::REV), "names the kind: {err}");
assert!(err.contains("settle-cost"), "names the currency: {err}");
assert!(
err.contains("work and record kinds"),
"names the admit set: {err}"
);
}
}