Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions crates/fluctlightdb/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ impl Default for BrainManifest {
"autonomic".into(),
"recent_separations".into(),
"semantic".into(),
"muon".into(),
"tau".into(),
],
}
}
Expand All @@ -62,6 +64,12 @@ pub fn save_v4_dir(brain: &FluctlightBrain, dir: &Path) -> Result<()> {
segment::write_segment(dir, "autonomic", &brain.autonomic)?;
segment::write_segment(dir, "recent_separations", &brain.recent_separations)?;
segment::write_segment(dir, "semantic", &brain.semantic)?;
// Muon/Tau lanes: persisted so imprints survive a restart. Previously these lived only in
// process memory, so every restart silently emptied them and recall returned 200 OK with no
// hits — indistinguishable from "no match" to a client. Both types already derived
// Serialize/Deserialize; they just were never written.
segment::write_segment(dir, "muon", &brain.muon)?;
segment::write_segment(dir, "tau", &brain.tau)?;

let manifest = BrainManifest {
format_version: V4_VERSION,
Expand Down Expand Up @@ -89,7 +97,7 @@ pub fn load_v4_dir(dir: &Path) -> Result<FluctlightBrain> {
manifest.format_version
)));
}
let brain = FluctlightBrain::from_snapshot(
let mut brain = FluctlightBrain::from_snapshot(
manifest.wal_seq,
segment::read_segment(dir, "life")?,
segment::read_segment(dir, "development")?,
Expand All @@ -98,12 +106,17 @@ pub fn load_v4_dir(dir: &Path) -> Result<FluctlightBrain> {
crate::legacy_hippocampus::read_hippocampus_segment(dir)?,
segment::read_segment(dir, "cortex")?,
segment::read_segment(dir, "amygdala")?,
segment::read_segment(dir, "prefrontal")?,
segment::read_segment(dir, "prefrontal").unwrap_or_default(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The prefrontal segment is silently swallowed on error with no mention in the PR description. Unlike muon/tau (explicitly new-to-this-PR, documented as optional for backward compat), prefrontal has been a mandatory v4 segment since before this change. Switching to unwrap_or_default() means a missing or corrupted prefrontal segment now loads as an empty default instead of surfacing an error, which could mask real data loss.

Suggested change
segment::read_segment(dir, "prefrontal").unwrap_or_default(),
segment::read_segment(dir, "prefrontal")?,

segment::read_segment(dir, "core_memories")?,
segment::read_segment(dir, "autonomic")?,
segment::read_segment(dir, "recent_separations")?,
segment::read_segment(dir, "semantic")?,
);
// Lane segments are optional: brains written before lane persistence have no muon/tau
// segment, so fall back to an empty lane instead of failing the whole load. That keeps
// older brain directories readable and matches the previous (always-empty) behaviour.
brain.muon = segment::read_segment(dir, "muon").unwrap_or_default();
brain.tau = segment::read_segment(dir, "tau").unwrap_or_default();
Ok(brain)
}

Expand Down Expand Up @@ -141,4 +154,46 @@ mod tests {
let loaded = load_v4_dir(&v4).unwrap();
assert_eq!(loaded.hippocampus.engrams.len(), 1);
}

/// Muon/Tau imprints must survive a save/load cycle. Before lane persistence these lanes
/// lived only in process memory, so a restart silently emptied them and recall returned
/// success with zero hits — a memory loss no client could detect.
#[test]
fn v4_roundtrip_preserves_muon_lane() {
std::env::set_var("FLUCTLIGHT_MUON", "1");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 set_var without teardown pollutes the test environment

std::env::set_var("FLUCTLIGHT_MUON", "1") mutates the process-global environment and is never cleaned up via std::env::remove_var. Rust runs tests in parallel threads within the same process, so every test that executes after v4_roundtrip_preserves_muon_lane will see FLUCTLIGHT_MUON=1, causing muon_enabled() to return true for all of them. Any test that exercises a code path gated on muon being disabled will silently run in the wrong mode. Rust 1.81 deprecated set_var specifically because of this multi-threaded unsoundness.

The fix is to remove the variable at the end of the test, or wrap the call in a serial_test with a scoped guard that restores the previous value.

let dir = tempdir().unwrap();
let v4 = dir.path().join("brain_v4");
let mut brain = FluctlightBrain::new();
brain.muon_imprint("sess-1", "2026-07-21", "the quick brown fox", "quick brown fox");
assert_eq!(brain.muon_len(), 1, "imprint should land in the lane");

save_v4_dir(&brain, &v4).unwrap();
let loaded = load_v4_dir(&v4).unwrap();

assert_eq!(
loaded.muon_len(),
1,
"muon imprints must survive save/load, not reset to empty"
);
assert!(
!loaded.muon_recall("quick brown fox", 4).is_empty(),
"a reloaded imprint must still be recallable"
);
}

/// A brain directory written before lane persistence has no muon/tau segment. Loading it
/// must still succeed (falling back to empty lanes) rather than erroring out.
#[test]
fn v4_load_tolerates_missing_lane_segments() {
let dir = tempdir().unwrap();
let v4 = dir.path().join("brain_v4");
let brain = FluctlightBrain::new();
save_v4_dir(&brain, &v4).unwrap();
// simulate an older brain dir: drop the lane segments
let _ = fs::remove_file(v4.join("muon.seg"));
let _ = fs::remove_file(v4.join("tau.seg"));

let loaded = load_v4_dir(&v4).expect("older brain dirs must still load");
assert_eq!(loaded.muon_len(), 0);
}
}
12 changes: 10 additions & 2 deletions crates/fluctlightdb/src/muon_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,26 @@ impl FluctlightBrain {
} else {
self.muon.imprint(session_id, date, body, user_keys);
}
// Count the imprint as a write so it participates in normal checkpointing. Without this
// the lane is only ever mutated in memory: it is never WAL-logged and never marks the
// brain dirty, so persisting the lane in the manifest alone still loses every imprint on
// restart. Errors are swallowed deliberately — a checkpoint failure must not make an
// imprint look like it failed, and the next checkpoint will retry.
let _ = self.maybe_checkpoint();
}

pub fn muon_imprint_batch(&mut self, sessions: &[MuonImprintInput]) -> usize {
if !muon_enabled() {
return 0;
}
if tau_enabled() {
let n = if tau_enabled() {
let (n, _) = self.tau.imprint_batch(sessions);
n
} else {
self.muon.imprint_batch(sessions)
}
};
let _ = self.maybe_checkpoint();
n
}

/// Penetrative recall — Tau episodic fission when `FLUCTLIGHT_TAU=1`, else session Muon hits.
Expand Down
69 changes: 69 additions & 0 deletions crates/fluctlightdb/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,75 @@ fn dispatch(
Ok(serde_json::json!({"ok": true, "engram_id": eid.to_string()}))
})
}
// ── CHORUS lane ──────────────────────────────────────────────────────────────
// The MaxSim⊕BM25 late-interaction stack was reachable only through the native/SDK
// API, which is unusable while `serve` holds the exclusive brain lock — so any
// client talking HTTP could not use it at all. These endpoints expose it.
"/api/v1/chorus/imprint" | "/chorus/imprint" => {
require_writable(server)?;
require_role(auth, Role::Write)?;
if !crate::chorus_runtime::chorus_enabled() {
return Err(Error::Store(
"FLUCTLIGHT_CHORUS=1 required for CHORUS imprint".into(),
));
}
let content = api_body
.content
.as_deref()
.ok_or_else(|| Error::Store("missing content".into()))?;
let memory_id = api_body
.doc_id
.as_deref()
.or(api_body.key.as_deref())
.ok_or_else(|| Error::Store("missing doc_id (memory_id)".into()))?;
let input = crate::chorus::ChorusImprintInput {
memory_id: memory_id.to_string(),
content: content.to_string(),
context: api_body.context.clone().unwrap_or_default(),
semantic_vector: api_body.semantic_vector.clone(),
token_vectors: None,
salience: api_body.salience.unwrap_or(0.7),
sheath: Default::default(),
};
server.with_brain_write(tenant_id, |b| {
let ok = b.chorus_imprint(&input);
Ok(serde_json::json!({
"ok": ok,
"memory_id": memory_id,
"chorus_len": b.chorus_len(),
}))
})
}
"/api/v1/chorus/recall" | "/chorus/recall" => {
require_role(auth, Role::Read)?;
if !crate::chorus_runtime::chorus_enabled() {
return Err(Error::Store(
"FLUCTLIGHT_CHORUS=1 required for CHORUS recall".into(),
));
}
let cue = api_body
.cue
.as_deref()
.or(api_body.content.as_deref())
.ok_or_else(|| Error::Store("missing cue".into()))?;
let k = api_body.limit.unwrap_or(8).min(64);
let cue_vector = api_body.semantic_vector.as_deref();
server.with_brain_read(tenant_id, |b| {
Ok(serde_json::json!({
"hits": b.chorus_recall(cue, k, cue_vector),
"chorus_len": b.chorus_len(),
}))
})
}
"/api/v1/chorus/stats" | "/chorus/stats" => {
require_role(auth, Role::Read)?;
server.with_brain_read(tenant_id, |b| {
Ok(serde_json::json!({
"enabled": crate::chorus_runtime::chorus_enabled(),
"chorus_len": b.chorus_len(),
}))
})
}
"/api/v1/export-graph-lite" | "/export-graph-lite" => {
require_role(auth, Role::Read)?;
server.with_brain_read(tenant_id, |b| {
Expand Down
Loading
Loading