Skip to content

Commit afafd0b

Browse files
committed
feat: make reconstruction loop termination explicit
1 parent 36e49df commit afafd0b

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

crates/nuif-reconstruct/src/lib.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,9 @@ pub struct LoopBudget {
920920
pub max_provider_calls: usize,
921921
pub max_millis: u64,
922922
pub max_estimated_bytes: usize,
923+
/// Optional objective threshold at or below which the candidate is a
924+
/// successful reconstruction and the loop terminates.
925+
pub stop_objective: Option<f64>,
923926
pub proposal_policy: ProposalPolicy,
924927
pub protected_metrics: Vec<ProtectedMetric>,
925928
}
@@ -931,6 +934,7 @@ impl Default for LoopBudget {
931934
max_provider_calls: 8,
932935
max_millis: 30_000,
933936
max_estimated_bytes: 32 * 1024 * 1024,
937+
stop_objective: None,
934938
proposal_policy: ProposalPolicy::default(),
935939
protected_metrics: Vec::new(),
936940
}
@@ -940,6 +944,8 @@ impl Default for LoopBudget {
940944
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
941945
#[serde(rename_all = "snake_case")]
942946
pub enum LoopStatus {
947+
Success,
948+
NoImprovement,
943949
NoProposal,
944950
RepeatedState,
945951
IterationBudget,
@@ -989,11 +995,23 @@ pub fn run_loop(
989995
.evaluate(document)
990996
.map_err(ReconstructionError::Evaluator)?;
991997
validate_score(&initial_score, &budget.protected_metrics)?;
998+
validate_stop_objective(budget.stop_objective)?;
992999
let mut score = initial_score.clone();
9931000
let mut seen = BTreeSet::from([canonical_hash(document)?]);
9941001
let mut attempts = Vec::new();
9951002
let mut provider_calls = 0_usize;
9961003
let mut accepted = 0_usize;
1004+
if objective_reached(&score, budget.stop_objective) {
1005+
return Ok(ReconstructionReport {
1006+
status: LoopStatus::Success,
1007+
provider_calls,
1008+
accepted,
1009+
initial_score,
1010+
final_score: score,
1011+
attempts,
1012+
final_hash: canonical_hash(document)?,
1013+
});
1014+
}
9971015
let mut status = LoopStatus::IterationBudget;
9981016
for iteration in 0..budget.max_iterations {
9991017
if provider_calls >= budget.max_provider_calls {
@@ -1044,6 +1062,13 @@ pub fn run_loop(
10441062
*document = candidate;
10451063
score = candidate_score;
10461064
accepted += 1;
1065+
if objective_reached(&score, budget.stop_objective) {
1066+
status = LoopStatus::Success;
1067+
break;
1068+
}
1069+
} else {
1070+
status = LoopStatus::NoImprovement;
1071+
break;
10471072
}
10481073
}
10491074
Ok(ReconstructionReport {
@@ -1057,6 +1082,18 @@ pub fn run_loop(
10571082
})
10581083
}
10591084

1085+
fn validate_stop_objective(stop_objective: Option<f64>) -> Result<(), ReconstructionError> {
1086+
if stop_objective.is_some_and(|value| !value.is_finite()) {
1087+
Err(ReconstructionError::InvalidScore)
1088+
} else {
1089+
Ok(())
1090+
}
1091+
}
1092+
1093+
fn objective_reached(score: &CandidateScore, stop_objective: Option<f64>) -> bool {
1094+
stop_objective.is_some_and(|threshold| score.objective <= threshold)
1095+
}
1096+
10601097
fn estimated_bytes(
10611098
document: &Document,
10621099
observations: &ObservationBundle,
@@ -1528,4 +1565,67 @@ mod tests {
15281565
Some("improved")
15291566
);
15301567
}
1568+
1569+
#[test]
1570+
fn correction_loop_reports_no_improvement_without_mutating_state() {
1571+
struct Provider;
1572+
impl CorrectionProvider for Provider {
1573+
fn propose(
1574+
&mut self,
1575+
document: &Document,
1576+
_: &ObservationBundle,
1577+
_: usize,
1578+
) -> Result<Option<Proposal>, String> {
1579+
Ok(Some(Proposal {
1580+
schema_version: 1,
1581+
provenance: InferenceProvenance {
1582+
method: "test-provider".to_owned(),
1583+
provider: provider("correction"),
1584+
observations: BTreeSet::from([ObservationId("root-geometry".to_owned())]),
1585+
confidence: Confidence::raw(0.5),
1586+
},
1587+
patch: Patch {
1588+
base_revision: Some(canonical_hash(document).unwrap()),
1589+
transactions: vec![Transaction {
1590+
id: 1,
1591+
operations: vec![Operation::Rename {
1592+
entity: EntityId::new(2),
1593+
name: Some("not-improved".to_owned()),
1594+
}],
1595+
}],
1596+
},
1597+
}))
1598+
}
1599+
}
1600+
struct Evaluator;
1601+
impl CandidateEvaluator for Evaluator {
1602+
fn evaluate(&mut self, _: &Document) -> Result<CandidateScore, String> {
1603+
Ok(CandidateScore {
1604+
objective: 1.0,
1605+
metrics: BTreeMap::new(),
1606+
})
1607+
}
1608+
}
1609+
1610+
let mut document = Document::empty(EntityId::new(1));
1611+
let entity = Entity::new(EntityId::new(2), EntityKind::Surface);
1612+
document.roots.push(entity.id);
1613+
document.entities.insert(entity.id, entity);
1614+
let report = run_loop(
1615+
&mut document,
1616+
&observations(),
1617+
&mut Provider,
1618+
&mut Evaluator,
1619+
&LoopBudget {
1620+
stop_objective: Some(0.0),
1621+
..LoopBudget::default()
1622+
},
1623+
)
1624+
.unwrap();
1625+
assert_eq!(report.status, LoopStatus::NoImprovement);
1626+
assert_eq!(report.accepted, 0);
1627+
assert_eq!(report.attempts.len(), 1);
1628+
assert!(!report.attempts[0].accepted);
1629+
assert_eq!(document.entities[&EntityId::new(2)].name, None);
1630+
}
15311631
}

crates/nuif-testing/src/bin/capture-reconstruction.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ fn run() -> Result<(), String> {
262262
let reconstruction_trials = vec![
263263
trial(
264264
"correction_improves_and_stops",
265-
correction.status == LoopStatus::RepeatedState
265+
correction.status == LoopStatus::Success
266266
&& correction.accepted == 1
267267
&& correction.final_score.objective == 0.0,
268268
),
@@ -564,6 +564,7 @@ fn correction_trial(
564564
&mut ImprovingProvider { entity },
565565
&mut ObjectiveEvaluator { entity },
566566
&LoopBudget {
567+
stop_objective: Some(0.0),
567568
protected_metrics: vec![ProtectedMetric {
568569
name: "validity".to_owned(),
569570
max_regression: 0.0,

0 commit comments

Comments
 (0)