1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
//! bd-1n0np.7.4 — audited contradiction resolution: propose → validate (→ apply
//! via curate).
//!
//! Resolutions follow ADR-0014 (propose → validate → apply). This module is the
//! contradiction-specific propose + validate stage: given a detected, decided
//! contradiction (winner vs loser, from `contradiction_guard`), it proposes the
//! appropriate resolution and bridges it to a curate [`CreateCurationCandidateInput`]
//! so the *existing* curate pipeline performs the audited apply (Supersede =
//! tombstone-with-pointer, Split = scope edit, Merge = consolidation), emitting
//! the curate audit rows.
//!
//! Critically, this NEVER auto-applies: it emits a *pending* curation candidate
//! that a human/agent must confirm through `ee curate accept`. The resolution kind
//! is chosen from the explicit conflict signal that produced the contradiction.
use crate::core::contradiction_detect::ExplicitConflictSignal;
use crate::curate::CandidateType;
use crate::db::CreateCurationCandidateInput;
/// Source-type tag recorded on curation candidates this module proposes, so the
/// curate surface can distinguish contradiction-driven resolutions.
pub const CONTRADICTION_RESOLUTION_SOURCE_TYPE: &str = "contradiction_resolution";
/// Default confidence attached to a proposed (unconfirmed) resolution.
pub const CONTRADICTION_RESOLUTION_PROPOSAL_CONFIDENCE: f32 = 0.5;
/// How an unresolved contradiction should be resolved (ADR-0014 vocabulary).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ContradictionResolutionKind {
/// The winner replaces the loser: tombstone-with-pointer (`curate supersede`).
Supersede,
/// Each side is scoped to its own validity/scope (`curate split`).
ScopeSplit,
/// The two are consolidated into one (`curate merge`).
Merge,
}
impl ContradictionResolutionKind {
/// Stable string form.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Supersede => "supersede",
Self::ScopeSplit => "scope_split",
Self::Merge => "merge",
}
}
/// The curate [`CandidateType`] this resolution applies through, so the
/// existing curate propose→validate→apply pipeline performs the audited
/// mutation.
#[must_use]
pub const fn candidate_type(self) -> CandidateType {
// bd-jkgta: a confirmed contradiction resolution always applies the same
// content-free mutation — "keep the winner, tombstone-with-pointer the
// loser" (the documented principle above). It must NOT map to
// Supersede/Split/Merge: those set CandidateType::requires_content() and
// the curate accept/apply path rejects them as `content_required_for_type`
// because the pure proposal carries no proposed_content. Tombstone is the
// content-free type matching that principle; the winner is recorded as the
// candidate `source_id`. The analytical kind is preserved in the rationale.
match self {
Self::Supersede | Self::ScopeSplit | Self::Merge => CandidateType::Tombstone,
}
}
/// Choose the resolution kind from the explicit conflict signal that produced
/// the contradiction. Conservative default is `Supersede` (the safest
/// confirmed proposal: keep the winner, tombstone-with-pointer the loser).
#[must_use]
pub const fn from_signal(signal: ExplicitConflictSignal) -> Self {
match signal {
// Genuinely opposed assertions / explicit supersession / trust split:
// the winner supersedes the loser.
ExplicitConflictSignal::ContradictionLink
| ExplicitConflictSignal::Supersession
| ExplicitConflictSignal::TrustOutcomeSplit
| ExplicitConflictSignal::RepeatedCoSelection => Self::Supersede,
// Near-duplicate-but-divergent content consolidates.
ExplicitConflictSignal::DuplicateDivergent => Self::Merge,
// Both true within different windows/scopes -> scope-split.
ExplicitConflictSignal::ValidityWindowOverlap => Self::ScopeSplit,
}
}
}
/// A proposed (NOT yet applied) contradiction resolution.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ContradictionResolutionProposal {
/// The memory kept / preferred (the contradiction-guard survivor).
pub winner_memory_id: String,
/// The memory acted on (superseded / scope-edited / merged away).
pub loser_memory_id: String,
pub kind: ContradictionResolutionKind,
/// The explicit signal that produced the contradiction.
pub signal: ExplicitConflictSignal,
/// Human-readable rationale recorded on the curation candidate.
pub rationale: String,
}
/// Validation failure for a proposed resolution.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ContradictionResolutionError {
/// Winner or loser id was blank.
BlankMemoryId,
/// Winner and loser are the same memory (not a contradiction).
SameMemory,
}
impl ContradictionResolutionError {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::BlankMemoryId => "blank_memory_id",
Self::SameMemory => "winner_and_loser_are_same_memory",
}
}
}
/// Propose a resolution for a decided contradiction (`winner` kept, `loser`
/// acted on). The kind is chosen from `signal`. Pure: builds a proposal, applies
/// nothing.
#[must_use]
pub fn propose_contradiction_resolution(
winner_memory_id: &str,
loser_memory_id: &str,
signal: ExplicitConflictSignal,
) -> ContradictionResolutionProposal {
let kind = ContradictionResolutionKind::from_signal(signal);
let rationale = format!(
"Contradiction ({}) between {} and {}: propose {} keeping {}.",
signal.as_str(),
winner_memory_id.trim(),
loser_memory_id.trim(),
kind.as_str(),
winner_memory_id.trim(),
);
ContradictionResolutionProposal {
winner_memory_id: winner_memory_id.trim().to_string(),
loser_memory_id: loser_memory_id.trim().to_string(),
kind,
signal,
rationale,
}
}
/// Validate a proposed resolution before it becomes a curation candidate.
///
/// # Errors
///
/// Returns [`ContradictionResolutionError`] if either memory id is blank or the
/// winner and loser are the same memory.
pub fn validate_contradiction_resolution(
proposal: &ContradictionResolutionProposal,
) -> Result<(), ContradictionResolutionError> {
if proposal.winner_memory_id.is_empty() || proposal.loser_memory_id.is_empty() {
return Err(ContradictionResolutionError::BlankMemoryId);
}
if proposal.winner_memory_id == proposal.loser_memory_id {
return Err(ContradictionResolutionError::SameMemory);
}
Ok(())
}
/// Bridge a validated resolution to a *pending* curate candidate input, so the
/// existing curate pipeline performs the audited apply. The candidate targets the
/// loser (the memory superseded/edited/merged); the winner is recorded as the
/// `source_id`. Status is `pending` — never auto-applied (ADR-0014: confirmed
/// proposal only).
#[must_use]
pub fn to_curation_candidate_input(
proposal: &ContradictionResolutionProposal,
workspace_id: &str,
) -> CreateCurationCandidateInput {
CreateCurationCandidateInput {
workspace_id: workspace_id.to_string(),
candidate_type: proposal.kind.candidate_type().as_str().to_string(),
target_memory_id: Some(proposal.loser_memory_id.clone()),
proposed_content: None,
proposed_confidence: None,
proposed_trust_class: None,
source_type: CONTRADICTION_RESOLUTION_SOURCE_TYPE.to_string(),
source_id: Some(proposal.winner_memory_id.clone()),
reason: proposal.rationale.clone(),
confidence: CONTRADICTION_RESOLUTION_PROPOSAL_CONFIDENCE,
status: Some("pending".to_string()),
created_at: None,
ttl_expires_at: None,
derivation_source_refs_json: None,
derivation_metadata_json: None,
}
}
#[cfg(test)]
mod tests {
use super::{
CONTRADICTION_RESOLUTION_SOURCE_TYPE, ContradictionResolutionError,
ContradictionResolutionKind, propose_contradiction_resolution, to_curation_candidate_input,
validate_contradiction_resolution,
};
use crate::core::contradiction_detect::ExplicitConflictSignal;
use crate::curate::CandidateType;
#[test]
fn signal_maps_to_resolution_kind_and_candidate_type() {
assert_eq!(
ContradictionResolutionKind::from_signal(ExplicitConflictSignal::ContradictionLink),
ContradictionResolutionKind::Supersede
);
assert_eq!(
ContradictionResolutionKind::from_signal(ExplicitConflictSignal::DuplicateDivergent),
ContradictionResolutionKind::Merge
);
assert_eq!(
ContradictionResolutionKind::from_signal(ExplicitConflictSignal::ValidityWindowOverlap),
ContradictionResolutionKind::ScopeSplit
);
// The analytical kind stays distinct, while every confirmed proposal
// bridges to the one content-free curate mutation that can preserve the
// winner as source_id and tombstone the loser without fabricated body
// content.
assert_eq!(
ContradictionResolutionKind::Supersede.candidate_type(),
CandidateType::Tombstone
);
assert_eq!(
ContradictionResolutionKind::ScopeSplit.candidate_type(),
CandidateType::Tombstone
);
assert_eq!(
ContradictionResolutionKind::Merge.candidate_type(),
CandidateType::Tombstone
);
}
#[test]
fn proposal_keeps_winner_targets_loser_and_validates() {
let proposal = propose_contradiction_resolution(
" mem_winner ",
"mem_loser",
ExplicitConflictSignal::ContradictionLink,
);
assert_eq!(proposal.winner_memory_id, "mem_winner");
assert_eq!(proposal.loser_memory_id, "mem_loser");
assert_eq!(proposal.kind, ContradictionResolutionKind::Supersede);
assert!(validate_contradiction_resolution(&proposal).is_ok());
assert!(proposal.rationale.contains("supersede"));
}
#[test]
fn validation_rejects_blank_and_self_contradiction() {
let blank = propose_contradiction_resolution(
"",
"mem_loser",
ExplicitConflictSignal::ContradictionLink,
);
assert_eq!(
validate_contradiction_resolution(&blank),
Err(ContradictionResolutionError::BlankMemoryId)
);
let same = propose_contradiction_resolution(
"mem_x",
"mem_x",
ExplicitConflictSignal::ContradictionLink,
);
assert_eq!(
validate_contradiction_resolution(&same),
Err(ContradictionResolutionError::SameMemory)
);
}
#[test]
fn bridges_to_pending_curate_candidate_targeting_loser() {
let proposal = propose_contradiction_resolution(
"mem_winner",
"mem_loser",
ExplicitConflictSignal::DuplicateDivergent,
);
let input = to_curation_candidate_input(&proposal, "wsp_test");
// bd-jkgta: applies through a content-free tombstone-with-pointer, not
// a content-requiring merge/split/supersede.
assert_eq!(input.candidate_type, "tombstone");
assert_eq!(input.target_memory_id.as_deref(), Some("mem_loser"));
assert_eq!(input.source_id.as_deref(), Some("mem_winner"));
assert_eq!(input.source_type, CONTRADICTION_RESOLUTION_SOURCE_TYPE);
// Never auto-applied: it is a pending candidate awaiting curate accept.
assert_eq!(input.status.as_deref(), Some("pending"));
}
#[test]
fn curate_candidate_uses_content_free_type_so_accept_can_apply_bd_jkgta() {
// bd-jkgta: every signal/kind must map to a CandidateType whose
// requires_content() is false, so the pending candidate (proposed_content
// None) does not hit the curate `content_required_for_type` branch and can
// actually be confirmed by `ee curate accept`.
for signal in [
ExplicitConflictSignal::ContradictionLink,
ExplicitConflictSignal::Supersession,
ExplicitConflictSignal::TrustOutcomeSplit,
ExplicitConflictSignal::RepeatedCoSelection,
ExplicitConflictSignal::DuplicateDivergent,
ExplicitConflictSignal::ValidityWindowOverlap,
] {
let proposal = propose_contradiction_resolution("mem_winner", "mem_loser", signal);
let candidate_type = proposal.kind.candidate_type();
assert!(
!candidate_type.requires_content(),
"candidate_type {candidate_type:?} for signal {signal:?} must be content-free"
);
let input = to_curation_candidate_input(&proposal, "wsp_test");
assert_eq!(input.proposed_content, None);
assert_eq!(input.target_memory_id.as_deref(), Some("mem_loser"));
assert_eq!(input.source_id.as_deref(), Some("mem_winner"));
}
}
}