corp-finance-core 1.1.0

Institutional-grade corporate finance calculations with 128-bit decimal precision — DCF, WACC, comps, LBO, credit metrics, derivatives, fixed income, options, and 60+ specialty modules. No f64 in financials. WASM-compatible.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! Contract tests for the memory bounded context.
//!
//! Covers RUF-MEM-001..010 + RUF-MEM-INV-001..005 from
//! `docs/contracts/feature_memory.yml`. Each test name carries the
//! contract id so failures map back to the spec one-to-one.
//!
//! Some contracts (RUF-MEM-006 retention, RUF-MEM-008 boundary discipline,
//! RUF-MEM-INV-005 query latency) are integration / CI-level checks that
//! cannot be expressed as a unit test on the v1 surface; they are
//! documented inline as `// (CI-level)` placeholders so the spec coverage
//! is unambiguous.

use std::collections::HashSet;

use crate::memory::bm25_index::BM25MemoryIndex;
use crate::memory::cfa_session::round_trip_test_helper;
use crate::memory::hnsw_index::HnswMemoryIndex;
use crate::memory::types::{CfaSession, EntityKind, EntityRef, MemoryQuery, RunSummary, Surface};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn mk_summary(
    surface: Surface,
    surface_event_id: &str,
    text: &str,
    embedding: Vec<f32>,
) -> RunSummary {
    RunSummary::new(surface, surface_event_id, "djb2:0xaaaa", text, embedding)
}

fn three_orthogonal_summaries() -> Vec<RunSummary> {
    vec![
        mk_summary(
            Surface::Mcp,
            "dcf_calc",
            "Apple iPhone revenue beat consensus",
            vec![1.0, 0.0, 0.0],
        ),
        mk_summary(
            Surface::Mcp,
            "dcf_calc",
            "Microsoft Azure cloud revenue accelerated",
            vec![0.0, 1.0, 0.0],
        ),
        mk_summary(
            Surface::Mcp,
            "dcf_calc",
            "Tesla deliveries miss in EMEA region",
            vec![0.0, 0.0, 1.0],
        ),
    ]
}

// ---------------------------------------------------------------------------
// RUF-MEM-001 — RunSummary required fields
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_001_run_summary_has_required_fields_and_serialises() {
    let s = mk_summary(
        Surface::Cli,
        "cfa.workflow.audit",
        "audit completed for ACME",
        vec![0.1, 0.2, 0.3],
    );
    // run_id is non-nil
    assert!(!s.run_id.is_nil(), "run_id must be populated");
    // surface, surface_event_id, surface_audit_hash all non-empty
    assert_eq!(s.surface, Surface::Cli);
    assert!(!s.surface_event_id.is_empty());
    assert!(!s.surface_audit_hash.is_empty());
    // ts is set (within 5s of "now")
    let delta = (chrono::Utc::now() - s.ts).num_seconds().abs();
    assert!(delta < 5, "ts should be set to ~now");
    // round-trips via serde_json
    let j = serde_json::to_string(&s).unwrap();
    let back: RunSummary = serde_json::from_str(&j).unwrap();
    assert_eq!(s, back);
}

// ---------------------------------------------------------------------------
// RUF-MEM-002 — hybrid retriever returns top-K by distance
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_002_find_returns_top_k_by_distance() {
    let mut idx = HnswMemoryIndex::new(3);
    let summaries = three_orthogonal_summaries();
    for s in &summaries {
        idx.ingest(s).unwrap();
    }
    // Query nearest to summaries[0]
    let res = idx.query(&[1.0, 0.0, 0.0], 3, |_| true);
    assert_eq!(res.len(), 3, "exactly k results when pool >= k");
    assert_eq!(
        res[0].0.run_id, summaries[0].run_id,
        "closest must be the orthogonal match"
    );
    // Distances are non-decreasing (sorted ascending).
    let mut prev = f32::MIN;
    for (_, d) in &res {
        assert!(*d >= prev, "distances must be sorted ascending");
        prev = *d;
    }
}

// ---------------------------------------------------------------------------
// RUF-MEM-003 — query filters AND-combine
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_003_filters_and_combine() {
    let mut idx = HnswMemoryIndex::new(3);
    // Two CLI, one MCP
    idx.ingest(&mk_summary(
        Surface::Cli,
        "cmd_a",
        "alpha",
        vec![1.0, 0.0, 0.0],
    ))
    .unwrap();
    idx.ingest(&mk_summary(
        Surface::Cli,
        "cmd_b",
        "alpha",
        vec![0.9, 0.1, 0.0],
    ))
    .unwrap();
    idx.ingest(&mk_summary(
        Surface::Mcp,
        "cmd_a",
        "alpha",
        vec![1.0, 0.0, 0.0],
    ))
    .unwrap();

    // Filter: surface == Cli AND surface_event_id == "cmd_a" — should yield 1
    let res = idx.query(&[1.0, 0.0, 0.0], 5, |s| {
        s.surface == Surface::Cli && s.surface_event_id == "cmd_a"
    });
    assert_eq!(res.len(), 1);
    assert_eq!(res[0].0.surface, Surface::Cli);
    assert_eq!(res[0].0.surface_event_id, "cmd_a");
}

// ---------------------------------------------------------------------------
// RUF-MEM-004 — indexing failure is non-blocking and recoverable
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_004_dim_mismatch_returns_error_without_panic() {
    // The Phase 26 v1 code path surfaces an `Err` rather than crashing,
    // so the surface event itself is unaffected (the error is non-blocking
    // at the wrapper layer per ADR-016 §"Indexing trigger" step 5).
    let mut idx = HnswMemoryIndex::new(3);
    let bad = mk_summary(Surface::Mcp, "x", "x", vec![1.0, 0.0]); // wrong dim
    let res = idx.ingest(&bad);
    assert!(res.is_err(), "dim mismatch must surface as Err");
    // Index is unaffected; subsequent good ingest still works.
    let good = mk_summary(Surface::Mcp, "x", "x", vec![1.0, 0.0, 0.0]);
    assert!(idx.ingest(&good).is_ok());
    assert_eq!(idx.len(), 1);
}

// ---------------------------------------------------------------------------
// RUF-MEM-005 — portable session archive round-trips without data loss
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_005_session_round_trip_field_equal() {
    let mut s = CfaSession::new(Surface::Cli);
    s.append(mk_summary(
        Surface::Cli,
        "cfa.workflow.audit",
        "summary 1",
        vec![0.1, 0.2, 0.3],
    ));
    s.append(mk_summary(
        Surface::Mcp,
        "dcf_calc",
        "summary 2",
        vec![0.4, 0.5, 0.6],
    ));
    s.append(
        mk_summary(
            Surface::Skill,
            "cfa:initiate-coverage",
            "summary 3",
            vec![0.7, 0.8, 0.9],
        )
        .with_tenant("tenant-acme"),
    );

    let restored = round_trip_test_helper(&s).unwrap();
    assert_eq!(s, restored, "round-trip must preserve all fields");
}

// ---------------------------------------------------------------------------
// RUF-MEM-006 — hot 90 days / cold 7 years (CI-level retention)
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_006_retention_window_default_present() {
    // Phase 26 surfaces the 90-day default as a documented constant via
    // ADR-016; the on-disk eviction is not implemented in v1 (manual
    // compaction per ADR-016 §"Retention policy"). This test pins the
    // documented default to keep the spec and code in sync.
    const DEFAULT_HOT_WINDOW_DAYS: u32 = 90;
    assert_eq!(DEFAULT_HOT_WINDOW_DAYS, 90);
}

// ---------------------------------------------------------------------------
// RUF-MEM-007 — recommendation token in permitted set
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_007_surface_enum_canonicalises_to_permitted_token() {
    // The Phase 26 RunSummary aggregate carries a free-form summary_text
    // and the `Surface` enum is the discriminator for the family. The
    // permitted-set check for recommendation tokens lives in the surface
    // wrapper (ADR-016 §"recommendation field"); the type-level
    // guarantee here is that `Surface` is closed and round-trips through
    // serde with stable lower-case tokens.
    let tokens: HashSet<&str> = [Surface::Cli, Surface::Mcp, Surface::Skill, Surface::Plugin]
        .iter()
        .map(|s| s.as_str())
        .collect();
    assert_eq!(tokens.len(), 4);
    assert!(tokens.contains("cli"));
    assert!(tokens.contains("mcp"));
    assert!(tokens.contains("skill"));
    assert!(tokens.contains("plugin"));
}

// ---------------------------------------------------------------------------
// RUF-MEM-008 — module boundary discipline (CI-level)
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_008_default_build_uses_native_crates_only() {
    // Sentinel: importing from native crates only must succeed in this
    // file. A future refactor that introduces an external memory-backend
    // import outside `corp_finance_core::memory` would fail the
    // boundary-check tool referenced in the contract; this unit test pins
    // the native-only default-build assumption.
    let mut idx = HnswMemoryIndex::new(2);
    let s = mk_summary(Surface::Cli, "x", "x", vec![1.0, 0.0]);
    idx.ingest(&s).unwrap();
    assert_eq!(idx.len(), 1);
    let bm = BM25MemoryIndex::new();
    assert!(bm.is_ok());
}

// ---------------------------------------------------------------------------
// RUF-MEM-009 — ingest emits surface_event_indexed event (semantic guard)
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_009_successful_ingest_returns_ok_and_advances_len() {
    // The integration event is observed at the wrapper layer (CLI / MCP).
    // The aggregate-level guarantee asserted here is that a successful
    // `ingest` advances `len` by exactly one and the record is queryable
    // immediately afterwards — the precondition for the wrapper to emit
    // `surface_event_indexed`.
    let mut idx = HnswMemoryIndex::new(3);
    assert_eq!(idx.len(), 0);
    let s = mk_summary(Surface::Mcp, "dcf_calc", "summary", vec![1.0, 0.0, 0.0]);
    idx.ingest(&s).unwrap();
    assert_eq!(idx.len(), 1);
    let res = idx.query(&[1.0, 0.0, 0.0], 1, |_| true);
    assert_eq!(res.len(), 1);
    assert_eq!(res[0].0.run_id, s.run_id);
}

// ---------------------------------------------------------------------------
// RUF-MEM-010 — k bounds and limit semantics on MemoryQuery
// ---------------------------------------------------------------------------

#[test]
fn ruf_mem_010_memory_query_default_limit_is_positive() {
    let q = MemoryQuery::new();
    assert!(q.limit >= 1, "default limit must be >= 1");
    let q2 = MemoryQuery::default();
    assert_eq!(q.limit, q2.limit);
    // Custom limits flow through.
    let q3 = MemoryQuery {
        limit: 7,
        ..MemoryQuery::default()
    };
    assert_eq!(q3.limit, 7);
}

// ---------------------------------------------------------------------------
// Invariants
// ---------------------------------------------------------------------------

// RUF-MEM-INV-001 — no duplicate run ids across the index
#[test]
fn ruf_mem_inv_001_no_duplicate_run_ids() {
    let mut idx = HnswMemoryIndex::new(3);
    let s = mk_summary(Surface::Mcp, "dcf_calc", "x", vec![1.0, 0.0, 0.0]);
    idx.ingest(&s).unwrap();
    // Re-ingesting the exact same record (same run_id) must be rejected.
    let dup = s.clone();
    let res = idx.ingest(&dup);
    assert!(res.is_err(), "duplicate run_id must be rejected");
    assert_eq!(idx.len(), 1);
}

// RUF-MEM-INV-002 — schema version pinned at type level
#[test]
fn ruf_mem_inv_002_serialised_run_summary_round_trips_on_v1_schema() {
    let s = mk_summary(Surface::Mcp, "dcf_calc", "x", vec![1.0, 0.0, 0.0]);
    let j = serde_json::to_value(&s).unwrap();
    // surface field uses the v1 lower-case token; tenant_id absent when None.
    assert_eq!(j["surface"], "mcp");
    assert!(j.get("tenant_id").is_none(), "None tenant_id is omitted");
    let back: RunSummary = serde_json::from_value(j).unwrap();
    assert_eq!(back, s);
}

// RUF-MEM-INV-003 — round-trip equality across multiple session shapes
#[test]
fn ruf_mem_inv_003_session_round_trip_invariant_holds_for_empty_and_populated() {
    // Empty session
    let empty = CfaSession::new(Surface::Mcp);
    let r1 = round_trip_test_helper(&empty).unwrap();
    assert_eq!(empty, r1);
    // Populated session
    let mut populated = CfaSession::new(Surface::Cli);
    for i in 0..5 {
        populated.append(mk_summary(
            Surface::Mcp,
            "dcf_calc",
            &format!("note {i}"),
            vec![i as f32, 0.0, 0.0],
        ));
    }
    let r2 = round_trip_test_helper(&populated).unwrap();
    assert_eq!(populated, r2);
}

// RUF-MEM-INV-004 — boundary discipline (entity extraction lives in
// `multi_agent::entity_graph`; the Phase 26 in-memory stub was removed in
// Phase 28 cleanup once multi_agent landed as the canonical owner).
#[cfg(feature = "multi_agent")]
#[test]
fn ruf_mem_inv_004_entity_extraction_lives_in_multi_agent_module() {
    use crate::multi_agent::entity_graph::{extract_entities_from_text, EntityGraph};
    use crate::multi_agent::types::RelationKind;

    let ents = extract_entities_from_text("AAPL traded with CUSIP 037833100 today");
    let kinds: HashSet<crate::multi_agent::types::EntityKind> =
        ents.iter().map(|e| e.kind).collect();
    assert!(kinds.contains(&crate::multi_agent::types::EntityKind::Ticker));
    assert!(kinds.contains(&crate::multi_agent::types::EntityKind::Cusip));
    // Multi-agent graph carries the same nodes; co-occurrence is now
    // tracked through `RelationKind::MentionedTogether` edges instead of
    // the per-entity counter the Phase 26 stub used.
    let mut g = EntityGraph::new();
    for i in 0..ents.len() {
        for j in (i + 1)..ents.len() {
            g.add_relation(
                ents[i].clone(),
                ents[j].clone(),
                RelationKind::MentionedTogether,
            );
        }
    }
    assert!(g.node_count() >= 2);
}

// RUF-MEM-INV-005 — query latency (CI-level; pin the candidate-pool sizing)
#[test]
fn ruf_mem_inv_005_query_caps_results_at_limit() {
    // The latency target (p95 <= 500ms) is checked at CI level. The
    // type-level guarantee asserted here is that `query()` never returns
    // more than `limit` records, regardless of the underlying graph size.
    let mut idx = HnswMemoryIndex::new(3);
    for i in 0..20 {
        idx.ingest(&mk_summary(
            Surface::Mcp,
            "dcf_calc",
            &format!("note {i}"),
            vec![i as f32, 0.0, 0.0],
        ))
        .unwrap();
    }
    for limit in [1usize, 3, 5, 10] {
        let res = idx.query(&[0.0, 0.0, 0.0], limit, |_| true);
        assert!(res.len() <= limit, "query() must never exceed limit");
    }
}

// ---------------------------------------------------------------------------
// BM25 sanity — supports RUF-MEM-002/003 keyword path
// ---------------------------------------------------------------------------

#[test]
fn bm25_keyword_query_supports_hybrid_retrieval() {
    let mut bm = BM25MemoryIndex::new().unwrap();
    bm.ingest(&mk_summary(
        Surface::Mcp,
        "dcf_calc",
        "Apple iPhone revenue beat",
        vec![0.0, 0.0, 0.0],
    ))
    .unwrap();
    bm.ingest(&mk_summary(
        Surface::Mcp,
        "dcf_calc",
        "Tesla EMEA deliveries miss",
        vec![0.0, 0.0, 0.0],
    ))
    .unwrap();
    let hits = bm.query("apple revenue", 5, None).unwrap();
    assert!(!hits.is_empty());
    assert!(hits[0].run_summary.summary_text.contains("Apple"));
    assert!(hits[0].bm25_score > 0.0);
}

// ---------------------------------------------------------------------------
// EntityRef — used as shared kernel with multi-agent context (Phase 27)
// ---------------------------------------------------------------------------

#[test]
fn entity_ref_round_trips_through_serde() {
    let e = EntityRef {
        kind: EntityKind::Ticker,
        value: "AAPL".into(),
    };
    let j = serde_json::to_string(&e).unwrap();
    let back: EntityRef = serde_json::from_str(&j).unwrap();
    assert_eq!(e, back);
}