diff --git a/src/rust/drift_core/src/lib.rs b/src/rust/drift_core/src/lib.rs index 9076f033..c95df0e5 100644 --- a/src/rust/drift_core/src/lib.rs +++ b/src/rust/drift_core/src/lib.rs @@ -153,16 +153,12 @@ fn write_v(out: &mut String, v: &V) { // ── Python-semantics helpers ──────────────────────────────────────────────── -/// Python round(x, 2) — banker's rounding on the exact binary double at the -/// 1e2 scale (same law as memory_scoring's py_round4 at 1e4, proved there). -fn py_round2(x: f64) -> f64 { - (x * 100.0).round_ties_even() / 100.0 -} - -/// Python round(x, 4) — the 1e4 twin (memory_scoring keeps its own private -/// copy; this crate mirrors it rather than reaching into privates). -fn py_round4(x: f64) -> f64 { - (x * 10000.0).round_ties_even() / 10000.0 +/// Python round(x, digits) — banker's rounding on the exact binary double +/// at the 10^digits scale (same law as memory_scoring's py_round4 at 1e4, +/// proved there; one generic serves every scale this module rounds to). +fn py_round(x: f64, digits: i32) -> f64 { + let scale = 10f64.powi(digits); + (x * scale).round_ties_even() / scale } fn as_str(v: Option<&Json>) -> Option<&str> { @@ -271,32 +267,31 @@ pub fn pre_commit_gate_entry(entry: &Json) -> V { // ── promotion lifecycle ───────────────────────────────────────────────────── +/// The oracle's failure shape: {"success": false, "reason": ...}. +fn fail_dict(reason: impl Into) -> V { + V::Obj(vec![ + ("success".into(), V::Bool(false)), + ("reason".into(), V::Str(reason.into())), + ]) +} + /// Promote an entry through the lifecycle (decision kernel): the facade /// SELECTs the row and passes it here; on success the returned new_status /// (and its derived promotion_eligible flag) are applied by the facade's /// UPDATE. Key order mirrors the oracle's insertion order. pub fn promote_entry(entry: Option<&Json>, entry_id: i64, now_iso: &str) -> V { let Some(entry) = entry else { - return V::Obj(vec![ - ("success".into(), V::Bool(false)), - ("reason".into(), V::Str("entry not found".into())), - ]); + return fail_dict("entry not found"); }; let current_status = as_str(entry.get("rock_status")) .unwrap_or("washable") .to_string(); if current_status == "rock" { - return V::Obj(vec![ - ("success".into(), V::Bool(false)), - ("reason".into(), V::Str("already rock".into())), - ]); + return fail_dict("already rock"); } let (eligible, reason) = check_promotion_eligibility(entry, now_iso); if !eligible { - return V::Obj(vec![ - ("success".into(), V::Bool(false)), - ("reason".into(), V::Str(reason)), - ]); + return fail_dict(reason); } let new_status = match current_status.as_str() { "washable" => "established", @@ -360,10 +355,10 @@ pub fn compute_drift_velocity(changes: i64, promotions: i64, days: i64) -> V { let velocity = changes as f64 / window as f64; let promotion_rate = promotions as f64 / window as f64; V::Obj(vec![ - ("changes_per_day".into(), V::F64(py_round2(velocity))), + ("changes_per_day".into(), V::F64(py_round(velocity, 2))), ( "promotions_per_day".into(), - V::F64(py_round2(promotion_rate)), + V::F64(py_round(promotion_rate, 2)), ), ("total_changes".into(), V::Int(changes)), ("total_promotions".into(), V::Int(promotions)), @@ -385,8 +380,11 @@ pub fn compute_drift_direction(total: i64, established: i64, rock: i64) -> V { ("total_entries".into(), V::Int(total)), ("established_count".into(), V::Int(established)), ("rock_count".into(), V::Int(rock)), - ("stability_ratio".into(), V::F64(py_round4(stability_ratio))), - ("rock_ratio".into(), V::F64(py_round4(rock_ratio))), + ( + "stability_ratio".into(), + V::F64(py_round(stability_ratio, 4)), + ), + ("rock_ratio".into(), V::F64(py_round(rock_ratio, 4))), ( "direction".into(), V::Str(