@@ -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" ) ]
942946pub 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+
10601097fn 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}
0 commit comments