Skip to content

Commit dbdcda7

Browse files
committed
fix: thread Rust decay history budget
1 parent 6c20992 commit dbdcda7

13 files changed

Lines changed: 371 additions & 20 deletions

crates/mc-module/src/decay_render.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ pub fn extract_m0_block(m0_text: &str, tag: &str) -> Option<String> {
367367
mod tests {
368368
use super::*;
369369
use serde::Deserialize;
370+
use sha2::{Digest, Sha256};
370371

371372
fn comp(
372373
start: i64,
@@ -657,6 +658,130 @@ mod tests {
657658
}
658659
}
659660

661+
#[test]
662+
fn redacted_store_shape_matches_ts_at_real_history_budgets() {
663+
#[derive(Deserialize)]
664+
struct ShapeFixture {
665+
compartments: Vec<RawComp>,
666+
}
667+
#[derive(Deserialize)]
668+
struct DifferentialCase {
669+
budget: f64,
670+
#[serde(rename = "tsCost")]
671+
ts_cost: usize,
672+
#[serde(rename = "tsTierCounts")]
673+
ts_tier_counts: [usize; 5],
674+
#[serde(rename = "bodySha256")]
675+
body_sha256: String,
676+
}
677+
#[derive(Deserialize)]
678+
struct DifferentialFixture {
679+
cases: Vec<DifferentialCase>,
680+
}
681+
682+
let shape: ShapeFixture =
683+
serde_json::from_str(include_str!("../testdata/decay-store-shape.json"))
684+
.expect("parse redacted store shape");
685+
assert_eq!(
686+
shape.compartments.len(),
687+
388,
688+
"fixture must preserve the store shape"
689+
);
690+
let compartments: Vec<DecayRenderCompartment> = shape
691+
.compartments
692+
.iter()
693+
.map(|raw| DecayRenderCompartment {
694+
start_message: raw.start,
695+
end_message: raw.end,
696+
title: raw.title.clone(),
697+
content: raw.content.clone(),
698+
start_date: raw.start_date.clone(),
699+
end_date: raw.end_date.clone(),
700+
p1: raw.p1.clone(),
701+
p2: raw.p2.clone(),
702+
p3: raw.p3.clone(),
703+
p4: raw.p4.clone(),
704+
importance: raw.importance,
705+
legacy: raw.legacy,
706+
})
707+
.collect();
708+
let differential: DifferentialFixture =
709+
serde_json::from_str(include_str!("../testdata/decay-store-differential.json"))
710+
.expect("parse TS differential table");
711+
assert_eq!(differential.cases.len(), 4);
712+
713+
let mut previous_cost = None;
714+
for case in &differential.cases {
715+
let body = render_decayed_compartments(
716+
&compartments,
717+
case.budget,
718+
mc_tokenizer::estimate_tokens,
719+
);
720+
let rust_cost = mc_tokenizer::estimate_tokens(&body);
721+
assert_eq!(
722+
rust_cost, case.ts_cost,
723+
"token cost drift at budget {}",
724+
case.budget
725+
);
726+
assert!(
727+
rust_cost as f64 <= case.budget || body.is_empty(),
728+
"render exceeded budget {} with {} tokens",
729+
case.budget,
730+
rust_cost
731+
);
732+
if let Some(previous) = previous_cost {
733+
assert!(
734+
rust_cost > previous,
735+
"shrinking budget must not grow rendered cost: previous {previous}, current {rust_cost}"
736+
);
737+
}
738+
previous_cost = Some(rust_cost);
739+
740+
let digest = Sha256::digest(body.as_bytes());
741+
let rust_hash = digest
742+
.iter()
743+
.map(|byte| format!("{byte:02x}"))
744+
.collect::<String>();
745+
assert_eq!(
746+
rust_hash, case.body_sha256,
747+
"byte drift at budget {}",
748+
case.budget
749+
);
750+
751+
let sections = if body.is_empty() {
752+
Vec::new()
753+
} else {
754+
body.split("\n\n").collect::<Vec<_>>()
755+
};
756+
let mut tier_counts = [0usize; 5];
757+
for compartment in &compartments {
758+
let heading = format!(
759+
"## {}-{}",
760+
compartment.start_message, compartment.end_message
761+
);
762+
let section = sections
763+
.iter()
764+
.find(|section| section.starts_with(&heading))
765+
.copied();
766+
let mut selected = 5usize;
767+
for tier in 1..=5u8 {
768+
if render_compartment_at_tier(compartment, tier).as_str()
769+
== section.unwrap_or("")
770+
{
771+
selected = tier as usize;
772+
break;
773+
}
774+
}
775+
tier_counts[selected - 1] += 1;
776+
}
777+
assert_eq!(
778+
tier_counts, case.ts_tier_counts,
779+
"tier drift at budget {}",
780+
case.budget
781+
);
782+
}
783+
}
784+
660785
#[test]
661786
fn render_tight_golden_matches_reference_with_real_estimator() {
662787
// The budget GUARD path: these cases use budgets tight enough that the TS

crates/mc-module/src/historian.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,7 @@ mod tests {
15171517
mid_turn: false,
15181518
prev_response_completed_at_ms: None,
15191519
request_observed_at_ms: None,
1520+
history_budget_tokens: None,
15201521
declared_trim: None,
15211522
}
15221523
}

crates/mc-module/src/lib.rs

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ use subc_protocol::{
9292
use transform::ReductionDecision;
9393
use transform::{transform_with_projection, DeclaredTrim, HistorianDiagnostics, TransformRequest};
9494

95-
/// The per-route binding: the project, harness, session-slot value, and render budget
96-
/// frozen at bind. Transform routes carry the durable session in `session`; MCP facade
95+
/// The per-route binding: the project, harness, session-slot value, and fallback render
96+
/// budget frozen at bind. Transform routes carry the durable session in `session`; MCP facade
9797
/// routes carry an instance token there and must resolve it before touching the store.
9898
/// The project is NEVER taken from a per-pass request field — a crafted request could
9999
/// spoof it to read another project's memories — so it lives here, keyed by the route
@@ -105,10 +105,8 @@ pub struct SessionBinding {
105105
pub session: String,
106106
pub model_key: Option<String>,
107107
pub config: McModuleConfig,
108-
/// The history budget (tokens) FROZEN at bind. Byte-affecting (a different budget → a
109-
/// different m0 trim → different bytes), so it's read once and never per-pass. A
110-
/// default for now (reading it from config is a later refinement); the freeze-once is
111-
/// the load-bearing part — it can't change mid-session.
108+
/// The fallback history budget (tokens) frozen at bind. A transform request may carry
109+
/// a newer harness-resolved value because config can change while the route remains open.
112110
pub history_budget_tokens: f64,
113111
}
114112

@@ -4113,6 +4111,19 @@ impl McHandler {
41134111
"inactive"
41144112
};
41154113
let historian = historian_status_summary(&loaded.meta.historian);
4114+
// When the Rust module is active, it manages the frozen m0 in its own store
4115+
// instead of the harness SQLite cache. Report the exact session-history slice so
4116+
// status attribution does not estimate size by summing all raw-history p1 rows.
4117+
let compartment_tokens = loaded
4118+
.core
4119+
.frozen_units
4120+
.iter()
4121+
.find(|unit| unit.key == "m0")
4122+
.and_then(|unit| {
4123+
decay_render::extract_m0_block(&unit.frozen_payload, "session-history")
4124+
})
4125+
.map(|block| mc_tokenizer::estimate_tokens(&block))
4126+
.unwrap_or(0);
41164127
let newest_pass_at = pass_trace
41174128
.as_ref()
41184129
.map(|trace| {
@@ -4152,6 +4163,7 @@ impl McHandler {
41524163
"row_version": loaded.row_version,
41534164
"boundary_present": !loaded.core.boundary_id.trim().is_empty(),
41544165
"compartment_count": compartment_count,
4166+
"compartment_tokens": compartment_tokens,
41554167
"pending_drop_count": pending_drop_count,
41564168
"usage": {
41574169
"current_total_input_tokens": loaded.meta.last_usage.as_ref().map_or(0, |usage| usage.current_total_input_tokens),
@@ -5174,7 +5186,13 @@ impl McHandler {
51745186
let producer_ctx = transform::ProducerContext {
51755187
project_path: &project_path,
51765188
project_directory: &project_path,
5177-
history_budget_tokens: binding.history_budget_tokens,
5189+
// The authority adapter resolves this from the model context limit and
5190+
// sends it on each pass. Keep the bind-time value only for older callers
5191+
// that omit the field, and reject unusable values without disabling decay.
5192+
history_budget_tokens: parsed
5193+
.history_budget_tokens
5194+
.filter(|budget| budget.is_finite() && *budget >= 0.0)
5195+
.unwrap_or(binding.history_budget_tokens),
51785196
memory_enabled: binding.config.memory_enabled,
51795197
now_ms: pass_now,
51805198
execute_threshold_percentage: binding.config.execute_threshold_percentage,
@@ -6494,6 +6512,7 @@ impl McHandler {
64946512
mid_turn: parsed.pass_inputs.mid_turn,
64956513
prev_response_completed_at_ms: None,
64966514
request_observed_at_ms: None,
6515+
history_budget_tokens: None,
64976516
declared_trim: parsed.declared_trim.clone(),
64986517
};
64996518
let shadow_project = shadow_project_path(&binding.session);
@@ -7100,9 +7119,8 @@ impl ModuleHandler for McHandler {
71007119
session: req.identity.session.clone(),
71017120
model_key: None,
71027121
config,
7103-
// Frozen at bind. Currently a default constant (reading it from config is a
7104-
// later refinement); the load-bearing part is the freeze-once — a different
7105-
// budget would change the rendered m0 bytes, so it can't move mid-session.
7122+
// Older callers may omit the per-pass budget. Keep a safe fallback on the
7123+
// route, while authority requests carry the harness-resolved value.
71067124
history_budget_tokens: memory_render::DEFAULT_HISTORY_BUDGET_TOKENS,
71077125
},
71087126
);
@@ -11992,6 +12010,42 @@ mod tests {
1199212010
.to_string()
1199312011
}
1199412012

12013+
#[tokio::test(flavor = "current_thread")]
12014+
async fn authority_transform_uses_request_history_budget_on_hard() {
12015+
let producer = Arc::new(ProducerState::default());
12016+
let (handler, store, _dir, _project) =
12017+
handler_with_store(Arc::clone(&producer), default_test_config());
12018+
store
12019+
.replace_compartments(
12020+
"ses",
12021+
&[
12022+
stored_comp(1, 1, 40, "m40", &"OLD ".repeat(200)),
12023+
stored_comp(2, 41, 80, "m80", &"NEW ".repeat(200)),
12024+
],
12025+
)
12026+
.unwrap();
12027+
let mut request = request(big_messages());
12028+
request["history_budget_tokens"] = json!(300.0);
12029+
12030+
let response = call_transform_request(&handler, request).await;
12031+
assert_eq!(response["action"], "HARD");
12032+
let m0 = m0_text(&response);
12033+
assert!(m0.contains("NEW"), "newest compartment remains at P1: {m0}");
12034+
assert!(
12035+
!m0.contains("OLD"),
12036+
"request budget must reach the HARD decay renderer: {m0}"
12037+
);
12038+
let status = tool_body(handler.handle_session_status_value(
12039+
7,
12040+
&json!({ "method": "session.status", "v": 1, "session_id": "ses" }),
12041+
));
12042+
let history = decay_render::extract_m0_block(&m0, "session-history").unwrap();
12043+
assert_eq!(
12044+
status["compartment_tokens"],
12045+
json!(mc_tokenizer::estimate_tokens(&history))
12046+
);
12047+
}
12048+
1199512049
#[tokio::test(flavor = "current_thread")]
1199612050
async fn handler_full_autonomous_cycle_fires_publishes_and_next_pass_folds() {
1199712051
let producer = Arc::new(ProducerState::default());

crates/mc-module/src/m0_compose.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ pub struct M0ComposeInputs<'a> {
9090
/// The expiry cutoff, FROZEN at the HARD (a memory expiring after this still renders;
9191
/// a later defer uses the same cutoff → identical bytes).
9292
pub now_ms: i64,
93-
/// The history budget in tokens (frozen at route bind). The decay renderer fits the
94-
/// compartments to it; under a loose budget the render is estimator-independent.
93+
/// The history budget in tokens selected for this frozen render decision. The decay
94+
/// renderer fits the compartments to it; under a loose budget the render is estimator-independent.
9595
pub history_budget_tokens: f64,
9696
/// System-role content that is no longer in the live tail because the current fold
9797
/// covers its ordinal. Passing it explicitly keeps m0 composition deterministic and

crates/mc-module/src/transform.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,16 @@ struct LegacyCkItemWire {
122122

123123
/// The project context the module composes m0/m1 FROM. Resolved once per request from the
124124
/// authenticated route binding (never a request body field) and threaded into the
125-
/// transform. Production ALWAYS supplies it; it carries the frozen render inputs (budget,
126-
/// expiry cutoff) so a HARD freezes them and later passes replay identical bytes.
125+
/// transform. Production ALWAYS supplies it; it carries the render inputs (budget,
126+
/// expiry cutoff) so the frozen render decision preserves them and later passes replay identical bytes.
127127
pub struct ProducerContext<'a> {
128128
/// The project identity the store reads key off (memories, mutation log, workspace).
129129
pub project_path: &'a str,
130130
/// The project directory on disk, for reading ARCHITECTURE.md / STRUCTURE.md.
131131
pub project_directory: &'a str,
132-
/// The history budget in tokens, FROZEN at route bind (byte-affecting: a different
133-
/// budget → a different m0 trim → different bytes, so it can't change mid-session).
132+
/// The history budget in tokens for this pass. Authority callers resolve it from the
133+
/// stable model limit and may refresh it after a config change; the route binding
134+
/// supplies a fallback for older callers. A cache-busting render pass freezes the selected value in m0.
134135
pub history_budget_tokens: f64,
135136
/// Whether memory tools and m0 memory rendering are enabled for this binding.
136137
pub memory_enabled: bool,
@@ -257,6 +258,11 @@ pub struct TransformRequest {
257258
/// and would inflate every gap by that delay. Request evidence only.
258259
#[serde(default, skip_serializing_if = "Option::is_none")]
259260
pub request_observed_at_ms: Option<u64>,
261+
/// History budget resolved by the harness from the stable context limit, threshold,
262+
/// and history-budget percentage. Authority transforms carry it per pass because a
263+
/// route can outlive a config reload; absent values use the bind-time fallback.
264+
#[serde(default, skip_serializing_if = "Option::is_none")]
265+
pub history_budget_tokens: Option<f64>,
260266
#[serde(default, skip_serializing_if = "Option::is_none")]
261267
pub declared_trim: Option<DeclaredTrim>,
262268
}
@@ -310,6 +316,8 @@ struct TransformRequestWire {
310316
#[serde(default)]
311317
request_observed_at_ms: Option<u64>,
312318
#[serde(default)]
319+
history_budget_tokens: Option<f64>,
320+
#[serde(default)]
313321
declared_trim: Option<DeclaredTrim>,
314322
}
315323

@@ -344,6 +352,7 @@ impl<'de> Deserialize<'de> for TransformRequest {
344352
mid_turn: wire.mid_turn,
345353
prev_response_completed_at_ms: wire.prev_response_completed_at_ms,
346354
request_observed_at_ms: wire.request_observed_at_ms,
355+
history_budget_tokens: wire.history_budget_tokens,
347356
declared_trim: wire.declared_trim,
348357
})
349358
}
@@ -5266,6 +5275,7 @@ mod tests {
52665275
mid_turn: false,
52675276
prev_response_completed_at_ms: None,
52685277
request_observed_at_ms: None,
5278+
history_budget_tokens: None,
52695279
declared_trim: None,
52705280
}
52715281
}
@@ -6403,6 +6413,7 @@ mod tests {
64036413
"full_array_fingerprint": "fp-full-array",
64046414
"messages": [{ "mid": "m", "ordinal": 7, "ck": text_message("m", "hello") }],
64056415
"usage": { "current_total_input_tokens": 1, "context_limit_tokens": 2 },
6416+
"history_budget_tokens": 42_000.0,
64066417
"provider_error": "prompt is too long"
64076418
});
64086419
let parsed: TransformRequest = serde_json::from_value(value).unwrap();
@@ -6415,6 +6426,7 @@ mod tests {
64156426
);
64166427
assert_eq!(parsed.messages[0].mid, "m");
64176428
assert_eq!(parsed.usage.unwrap().context_limit_tokens, 2);
6429+
assert_eq!(parsed.history_budget_tokens, Some(42_000.0));
64186430
assert_eq!(parsed.provider_error.as_deref(), Some("prompt is too long"));
64196431
}
64206432

0 commit comments

Comments
 (0)