add observations engine and acknowledgement journal
This commit is contained in:
parent
fcdfa8437f
commit
5be11b2f09
3 changed files with 1686 additions and 1 deletions
969
src/analytics/observations.zig
Normal file
969
src/analytics/observations.zig
Normal file
|
|
@ -0,0 +1,969 @@
|
|||
//! Observations engine: portfolio sanity checks for the review surface.
|
||||
//!
|
||||
//! Each "check" is a self-contained function that examines the live
|
||||
//! review-view data (rows + totals) and returns a `CheckResult`:
|
||||
//!
|
||||
//! - `pass` — the check ran, no issue.
|
||||
//! - `warn` — approaching a threshold; user should pay attention.
|
||||
//! - `flag` — over a threshold; user should consider acting.
|
||||
//! - `skipped` — the check is registered but disabled for this run
|
||||
//! (drift falls into this slot until temporal observations ship).
|
||||
//! - `err` — the check ran but couldn't compute (missing data, etc).
|
||||
//!
|
||||
//! The engine runs registered checks via `runChecks` and returns a
|
||||
//! `CheckPanel` that the renderer reads to draw the status grid + the
|
||||
//! findings table. Checks are async-ready via `is_long_running`: today
|
||||
//! every check is sync, but the contract supports background dispatch
|
||||
//! via `std.Io.async` for future slow checks (drift detection over
|
||||
//! snapshot diffs, benchmark-relative thresholds, etc).
|
||||
//!
|
||||
//! ## Threshold scaling
|
||||
//!
|
||||
//! Concentration thresholds (position, sector) scale with portfolio
|
||||
//! size — fixed-percentage thresholds break for portfolios outside the
|
||||
//! typical 10-30 position range. Each scale-aware check uses a
|
||||
//! multiplier-with-clamps formula:
|
||||
//!
|
||||
//! threshold = clamp(multiplier × equal_weight, floor, cap)
|
||||
//!
|
||||
//! For 27 positions, equal_weight = 3.7%, position warn at 4× = ~15%
|
||||
//! (clamps at floor 10%). For 10 positions, equal_weight = 10%, warn
|
||||
//! at 4× = 40% (clamps at cap 50%). Multipliers + clamps tuned for
|
||||
//! typical real-world portfolios; revisit annually via the
|
||||
//! `observation_thresholds_last_reviewed` staleness anchor below.
|
||||
|
||||
const std = @import("std");
|
||||
const Date = @import("../Date.zig");
|
||||
const review_view = @import("../views/review.zig");
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────
|
||||
|
||||
/// Severity of a flagged finding. `pass` and `skipped` checks don't
|
||||
/// produce findings; only warn / flag / err do.
|
||||
pub const Severity = enum { warn, flag, err };
|
||||
|
||||
/// One finding produced by a check. Multiple findings per check are
|
||||
/// allowed (e.g. position concentration emits one per overweight
|
||||
/// holding). Pure data; allocator-owned.
|
||||
pub const Observation = struct {
|
||||
severity: Severity,
|
||||
/// Stable observation kind — the `Check.name` of the check that
|
||||
/// produced this finding. Used by the journal as the
|
||||
/// `observation` field of an `Acknowledgment`.
|
||||
kind: []const u8,
|
||||
/// Per-check target string convention. `"NVDA"` for single-symbol
|
||||
/// observations; `"sector:Technology"` for sector-scoped;
|
||||
/// `"VTI,SCHD"` for pair-based dominance. The journal looks up
|
||||
/// acks by `(observation, target)` so the convention must be
|
||||
/// stable per check.
|
||||
target: []const u8,
|
||||
/// Human-readable text rendered in the findings table. Includes
|
||||
/// thresholds and current value; e.g. "NVDA at 18.2% of liquid
|
||||
/// (warn at 14.8%, flag at 22.2%)".
|
||||
text: []const u8,
|
||||
};
|
||||
|
||||
/// Result of running one check. Allocator semantics: `warn` and
|
||||
/// `flag` slices, plus the strings inside their `Observation`s, are
|
||||
/// allocated by the runner against the panel's allocator and freed
|
||||
/// in `CheckPanel.deinit`. `err` strings are similarly allocated.
|
||||
pub const CheckResult = union(enum) {
|
||||
pass,
|
||||
warn: []const Observation,
|
||||
flag: []const Observation,
|
||||
skipped,
|
||||
err: []const u8,
|
||||
};
|
||||
|
||||
/// Inputs every check sees. The `allocator` is for the check to
|
||||
/// allocate its result strings against; the runner takes ownership
|
||||
/// of those allocations at the end of the dispatch.
|
||||
pub const CheckCtx = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
rows: []const review_view.ReviewRow,
|
||||
totals: review_view.ReviewTotals,
|
||||
};
|
||||
|
||||
/// Definition of a single check. Multiple `Check` values are
|
||||
/// registered in `default_checks` below.
|
||||
pub const Check = struct {
|
||||
/// Stable identifier; used as the `observation` field on journal
|
||||
/// acks. Snake_case, no whitespace. Changing this is a breaking
|
||||
/// change for any portfolio with existing acks.
|
||||
name: []const u8,
|
||||
/// Human-readable label for the status grid. ~21 chars or less
|
||||
/// for the current 4-column layout budget.
|
||||
label: []const u8,
|
||||
/// Hint to the engine: should this run on a background thread?
|
||||
/// All milestone-2 checks are pure-CPU and complete in
|
||||
/// microseconds; this stays `false`. Drift detection (when it
|
||||
/// ships) sets it `true` because snapshot diffing is I/O.
|
||||
is_long_running: bool = false,
|
||||
/// The check itself. Pure function over `CheckCtx`. Returns a
|
||||
/// `CheckResult` whose owned allocations belong to `ctx.allocator`.
|
||||
run: *const fn (ctx: CheckCtx) CheckResult,
|
||||
};
|
||||
|
||||
/// Per-check execution state, lives on `CheckPanel`. Today every
|
||||
/// check is sync so `state` is always `.complete` immediately after
|
||||
/// `runChecks` returns. The async path is in place for future use.
|
||||
pub const PendingCheck = struct {
|
||||
check: *const Check,
|
||||
state: union(enum) {
|
||||
complete: CheckResult,
|
||||
// Future: pending: std.Io.Future(CheckResult), populated
|
||||
// by io.async() and resolved via poll/await. Adding a
|
||||
// variant here is a non-breaking API change for current
|
||||
// consumers.
|
||||
},
|
||||
|
||||
/// Returns the resolved `CheckResult`, awaiting if necessary.
|
||||
/// Idempotent.
|
||||
pub fn awaitResult(self: *PendingCheck, io: std.Io) CheckResult {
|
||||
_ = io;
|
||||
return switch (self.state) {
|
||||
.complete => |r| r,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Runtime-built panel of checks + their results. Owned by the
|
||||
/// caller; `deinit` releases all allocated memory (including the
|
||||
/// per-finding strings inside each `CheckResult`).
|
||||
pub const CheckPanel = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
pending: []PendingCheck,
|
||||
|
||||
pub fn deinit(self: *CheckPanel) void {
|
||||
for (self.pending) |pc| freeResult(self.allocator, pc.state.complete);
|
||||
self.allocator.free(self.pending);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// True iff every check has a resolved result. Today always
|
||||
/// returns `true` immediately after `runChecks` (sync only).
|
||||
pub fn isComplete(self: *const CheckPanel) bool {
|
||||
for (self.pending) |pc| {
|
||||
switch (pc.state) {
|
||||
.complete => {},
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/// Free the strings owned by a `CheckResult`. Called from
|
||||
/// `CheckPanel.deinit`; checks themselves allocate against
|
||||
/// `ctx.allocator`.
|
||||
fn freeResult(a: std.mem.Allocator, result: CheckResult) void {
|
||||
switch (result) {
|
||||
.pass, .skipped => {},
|
||||
.warn => |obs| freeObservations(a, obs),
|
||||
.flag => |obs| freeObservations(a, obs),
|
||||
.err => |msg| a.free(msg),
|
||||
}
|
||||
}
|
||||
|
||||
fn freeObservations(a: std.mem.Allocator, obs: []const Observation) void {
|
||||
for (obs) |o| {
|
||||
a.free(o.kind);
|
||||
a.free(o.target);
|
||||
a.free(o.text);
|
||||
}
|
||||
a.free(obs);
|
||||
}
|
||||
|
||||
// ── Runner ────────────────────────────────────────────────────
|
||||
|
||||
/// Run the registered checks against the given context. Returns a
|
||||
/// `CheckPanel` that renderers consume. Sync today; the contract
|
||||
/// allows a future async dispatch path for `is_long_running` checks.
|
||||
pub fn runChecks(
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
ctx: CheckCtx,
|
||||
checks: []const Check,
|
||||
) !CheckPanel {
|
||||
_ = io;
|
||||
var pending = try allocator.alloc(PendingCheck, checks.len);
|
||||
errdefer allocator.free(pending);
|
||||
|
||||
for (checks, 0..) |*check, i| {
|
||||
const result = check.run(ctx);
|
||||
pending[i] = .{ .check = check, .state = .{ .complete = result } };
|
||||
}
|
||||
|
||||
return .{ .allocator = allocator, .pending = pending };
|
||||
}
|
||||
|
||||
// ── Threshold constants ───────────────────────────────────────
|
||||
//
|
||||
// See module-level comment for the multiplier-with-clamps rationale.
|
||||
|
||||
// Position concentration: a single holding too large relative to a
|
||||
// portfolio of N positions.
|
||||
const position_warn_multiplier: f64 = 4.0;
|
||||
const position_warn_floor: f64 = 0.10;
|
||||
const position_warn_cap: f64 = 0.50;
|
||||
const position_flag_multiplier: f64 = 6.0;
|
||||
const position_flag_floor: f64 = 0.15;
|
||||
const position_flag_cap: f64 = 0.70;
|
||||
|
||||
// Sector concentration: too much weight in a single sector relative
|
||||
// to a portfolio of M represented sectors.
|
||||
const sector_warn_multiplier: f64 = 2.5;
|
||||
const sector_warn_floor: f64 = 0.20;
|
||||
const sector_warn_cap: f64 = 0.60;
|
||||
const sector_flag_multiplier: f64 = 4.0;
|
||||
const sector_flag_floor: f64 = 0.30;
|
||||
const sector_flag_cap: f64 = 0.75;
|
||||
|
||||
// Vol outlier: ratio of holding's 3Y vol to portfolio 3Y vol.
|
||||
const vol_outlier_warn_ratio: f64 = 1.8;
|
||||
const vol_outlier_flag_ratio: f64 = 2.5;
|
||||
|
||||
// Sector dominance: Sharpe spread within a same-sector pair, where
|
||||
// both holdings have weight > min_weight_factor × equal_weight.
|
||||
const dominance_warn_spread: f64 = 0.4;
|
||||
const dominance_flag_spread: f64 = 0.7;
|
||||
const dominance_min_weight_factor: f64 = 0.5;
|
||||
|
||||
// Tiny position: relative weight floor (no absolute-dollar
|
||||
// threshold; relative-only by user decision).
|
||||
const tiny_warn_weight: f64 = 0.005;
|
||||
const tiny_flag_weight: f64 = 0.0025;
|
||||
|
||||
/// Annual sanity-check anchor for the threshold constants above.
|
||||
/// Like the review-tab MaxDD anchor, these values are calibrated
|
||||
/// against typical portfolios; they may need tuning over time.
|
||||
///
|
||||
/// Annual recheck procedure:
|
||||
/// 1. Run `zfin review` against your portfolio.
|
||||
/// 2. Status grid should show ✅ on most checks for a well-
|
||||
/// diversified portfolio; ⚠️ or ❌ should be rare and
|
||||
/// intentional.
|
||||
/// 3. If a check ALWAYS flags or NEVER flags across reasonable
|
||||
/// portfolios, the multipliers / clamps need adjustment.
|
||||
///
|
||||
/// Bump the date when satisfied; otherwise tune first then bump.
|
||||
///
|
||||
/// Registered with the staleness checker in `src/data/staleness.zig`.
|
||||
pub const observation_thresholds_last_reviewed: Date = Date.fromYmd(2026, 6, 8);
|
||||
|
||||
// ── Default check registry ────────────────────────────────────
|
||||
|
||||
pub const default_checks = [_]Check{
|
||||
.{
|
||||
.name = "position_concentration",
|
||||
.label = "Position concentration",
|
||||
.run = checkPositionConcentration,
|
||||
},
|
||||
.{
|
||||
.name = "sector_concentration",
|
||||
.label = "Sector concentration",
|
||||
.run = checkSectorConcentration,
|
||||
},
|
||||
.{
|
||||
.name = "sector_dominance",
|
||||
.label = "Sector dominance",
|
||||
.run = checkSectorDominance,
|
||||
},
|
||||
.{
|
||||
.name = "vol_outlier",
|
||||
.label = "Vol outlier",
|
||||
.run = checkVolOutlier,
|
||||
},
|
||||
.{
|
||||
.name = "tiny_position",
|
||||
.label = "Tiny position",
|
||||
.run = checkTinyPosition,
|
||||
},
|
||||
.{
|
||||
.name = "drift",
|
||||
.label = "Drift since last view",
|
||||
.is_long_running = true, // future: snapshot diff is I/O-bound
|
||||
.run = checkDrift,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Check implementations ─────────────────────────────────────
|
||||
|
||||
fn checkPositionConcentration(ctx: CheckCtx) CheckResult {
|
||||
if (ctx.rows.len == 0) return .pass;
|
||||
const n: f64 = @floatFromInt(ctx.rows.len);
|
||||
const equal_weight = 1.0 / n;
|
||||
const warn_thresh = std.math.clamp(position_warn_multiplier * equal_weight, position_warn_floor, position_warn_cap);
|
||||
const flag_thresh = std.math.clamp(position_flag_multiplier * equal_weight, position_flag_floor, position_flag_cap);
|
||||
|
||||
return collectFindingsByWeight(
|
||||
ctx,
|
||||
"position_concentration",
|
||||
warn_thresh,
|
||||
flag_thresh,
|
||||
&positionFindingsBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
fn positionFindingsBuilder(
|
||||
a: std.mem.Allocator,
|
||||
row: review_view.ReviewRow,
|
||||
severity: Severity,
|
||||
warn_thresh: f64,
|
||||
flag_thresh: f64,
|
||||
) !Observation {
|
||||
const text = try std.fmt.allocPrint(a, "{s} at {d:.1}% of liquid (warn at {d:.1}%, flag at {d:.1}%)", .{
|
||||
row.symbol,
|
||||
row.weight * 100.0,
|
||||
warn_thresh * 100.0,
|
||||
flag_thresh * 100.0,
|
||||
});
|
||||
return .{
|
||||
.severity = severity,
|
||||
.kind = try a.dupe(u8, "position_concentration"),
|
||||
.target = try a.dupe(u8, row.symbol),
|
||||
.text = text,
|
||||
};
|
||||
}
|
||||
|
||||
const FindingBuilder = *const fn (
|
||||
a: std.mem.Allocator,
|
||||
row: review_view.ReviewRow,
|
||||
severity: Severity,
|
||||
warn: f64,
|
||||
flag: f64,
|
||||
) anyerror!Observation;
|
||||
|
||||
fn collectFindingsByWeight(
|
||||
ctx: CheckCtx,
|
||||
kind: []const u8,
|
||||
warn_thresh: f64,
|
||||
flag_thresh: f64,
|
||||
builder: FindingBuilder,
|
||||
) CheckResult {
|
||||
_ = kind;
|
||||
var findings = std.ArrayList(Observation).empty;
|
||||
errdefer {
|
||||
for (findings.items) |o| {
|
||||
ctx.allocator.free(o.kind);
|
||||
ctx.allocator.free(o.target);
|
||||
ctx.allocator.free(o.text);
|
||||
}
|
||||
findings.deinit(ctx.allocator);
|
||||
}
|
||||
var has_flag = false;
|
||||
var has_warn = false;
|
||||
|
||||
for (ctx.rows) |row| {
|
||||
const sev: ?Severity = if (row.weight >= flag_thresh)
|
||||
.flag
|
||||
else if (row.weight >= warn_thresh)
|
||||
.warn
|
||||
else
|
||||
null;
|
||||
const s = sev orelse continue;
|
||||
const obs = builder(ctx.allocator, row, s, warn_thresh, flag_thresh) catch return errResult(ctx.allocator, "allocation failed");
|
||||
findings.append(ctx.allocator, obs) catch return errResult(ctx.allocator, "allocation failed");
|
||||
if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
|
||||
}
|
||||
|
||||
if (findings.items.len == 0) return .pass;
|
||||
const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "allocation failed");
|
||||
|
||||
// If any finding is a flag, the whole check is `flag`. Otherwise
|
||||
// it's `warn`. The per-finding severity inside the slice tells
|
||||
// the renderer what color each row gets; the wrapping variant
|
||||
// tells the status-grid glyph what to show.
|
||||
if (has_flag) return .{ .flag = slice };
|
||||
if (has_warn) return .{ .warn = slice };
|
||||
return .pass;
|
||||
}
|
||||
|
||||
fn errResult(a: std.mem.Allocator, msg: []const u8) CheckResult {
|
||||
const owned = a.dupe(u8, msg) catch return .pass; // fallback: silently pass on alloc failure
|
||||
return .{ .err = owned };
|
||||
}
|
||||
|
||||
fn checkSectorConcentration(ctx: CheckCtx) CheckResult {
|
||||
if (ctx.rows.len == 0) return .pass;
|
||||
|
||||
// Aggregate weight per sector.
|
||||
var sector_weights = std.StringHashMap(f64).init(ctx.allocator);
|
||||
defer sector_weights.deinit();
|
||||
|
||||
for (ctx.rows) |row| {
|
||||
const existing = sector_weights.get(row.sector_mid) orelse 0;
|
||||
sector_weights.put(row.sector_mid, existing + row.weight) catch return errResult(ctx.allocator, "alloc failed");
|
||||
}
|
||||
|
||||
const m: f64 = @floatFromInt(sector_weights.count());
|
||||
if (m == 0) return .pass;
|
||||
const equal_weight = 1.0 / m;
|
||||
const warn_thresh = std.math.clamp(sector_warn_multiplier * equal_weight, sector_warn_floor, sector_warn_cap);
|
||||
const flag_thresh = std.math.clamp(sector_flag_multiplier * equal_weight, sector_flag_floor, sector_flag_cap);
|
||||
|
||||
var findings = std.ArrayList(Observation).empty;
|
||||
errdefer {
|
||||
for (findings.items) |o| {
|
||||
ctx.allocator.free(o.kind);
|
||||
ctx.allocator.free(o.target);
|
||||
ctx.allocator.free(o.text);
|
||||
}
|
||||
findings.deinit(ctx.allocator);
|
||||
}
|
||||
var has_flag = false;
|
||||
var has_warn = false;
|
||||
|
||||
var it = sector_weights.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const weight = entry.value_ptr.*;
|
||||
const sev: ?Severity = if (weight >= flag_thresh)
|
||||
.flag
|
||||
else if (weight >= warn_thresh)
|
||||
.warn
|
||||
else
|
||||
null;
|
||||
const s = sev orelse continue;
|
||||
const text = std.fmt.allocPrint(ctx.allocator, "{s} sector at {d:.1}% (warn at {d:.1}%, flag at {d:.1}%)", .{
|
||||
entry.key_ptr.*,
|
||||
weight * 100.0,
|
||||
warn_thresh * 100.0,
|
||||
flag_thresh * 100.0,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
const target = std.fmt.allocPrint(ctx.allocator, "sector:{s}", .{entry.key_ptr.*}) catch {
|
||||
ctx.allocator.free(text);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
const kind = ctx.allocator.dupe(u8, "sector_concentration") catch {
|
||||
ctx.allocator.free(text);
|
||||
ctx.allocator.free(target);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
findings.append(ctx.allocator, .{
|
||||
.severity = s,
|
||||
.kind = kind,
|
||||
.target = target,
|
||||
.text = text,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
|
||||
}
|
||||
|
||||
if (findings.items.len == 0) return .pass;
|
||||
const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (has_flag) return .{ .flag = slice };
|
||||
if (has_warn) return .{ .warn = slice };
|
||||
return .pass;
|
||||
}
|
||||
|
||||
fn checkSectorDominance(ctx: CheckCtx) CheckResult {
|
||||
if (ctx.rows.len < 2) return .pass;
|
||||
const n: f64 = @floatFromInt(ctx.rows.len);
|
||||
const min_weight = (1.0 / n) * dominance_min_weight_factor;
|
||||
|
||||
var findings = std.ArrayList(Observation).empty;
|
||||
errdefer {
|
||||
for (findings.items) |o| {
|
||||
ctx.allocator.free(o.kind);
|
||||
ctx.allocator.free(o.target);
|
||||
ctx.allocator.free(o.text);
|
||||
}
|
||||
findings.deinit(ctx.allocator);
|
||||
}
|
||||
var has_flag = false;
|
||||
var has_warn = false;
|
||||
|
||||
// O(n²) walk over same-sector pairs. With n typically <50,
|
||||
// this should be 2500 comparisons of f64s
|
||||
for (ctx.rows, 0..) |a_row, i| {
|
||||
if (a_row.weight < min_weight) continue;
|
||||
const a_sharpe = a_row.sharpe_3y orelse continue;
|
||||
for (ctx.rows[i + 1 ..]) |b_row| {
|
||||
if (b_row.weight < min_weight) continue;
|
||||
if (!std.mem.eql(u8, a_row.sector_mid, b_row.sector_mid)) continue;
|
||||
const b_sharpe = b_row.sharpe_3y orelse continue;
|
||||
const spread = @abs(a_sharpe - b_sharpe);
|
||||
const sev: ?Severity = if (spread >= dominance_flag_spread)
|
||||
.flag
|
||||
else if (spread >= dominance_warn_spread)
|
||||
.warn
|
||||
else
|
||||
null;
|
||||
const s = sev orelse continue;
|
||||
|
||||
// The "dominant" holding is the higher-Sharpe one.
|
||||
const winner = if (a_sharpe > b_sharpe) a_row.symbol else b_row.symbol;
|
||||
const loser = if (a_sharpe > b_sharpe) b_row.symbol else a_row.symbol;
|
||||
const winner_sharpe = if (a_sharpe > b_sharpe) a_sharpe else b_sharpe;
|
||||
const loser_sharpe = if (a_sharpe > b_sharpe) b_sharpe else a_sharpe;
|
||||
|
||||
const text = std.fmt.allocPrint(ctx.allocator, "{s} ({d:.2} 3Y Sharpe) outperforms {s} ({d:.2}) in same sector ({s}); spread {d:.2}", .{
|
||||
winner,
|
||||
winner_sharpe,
|
||||
loser,
|
||||
loser_sharpe,
|
||||
a_row.sector_mid,
|
||||
spread,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
const target = std.fmt.allocPrint(ctx.allocator, "{s},{s}", .{ winner, loser }) catch {
|
||||
ctx.allocator.free(text);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
const kind = ctx.allocator.dupe(u8, "sector_dominance") catch {
|
||||
ctx.allocator.free(text);
|
||||
ctx.allocator.free(target);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
findings.append(ctx.allocator, .{
|
||||
.severity = s,
|
||||
.kind = kind,
|
||||
.target = target,
|
||||
.text = text,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.items.len == 0) return .pass;
|
||||
const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (has_flag) return .{ .flag = slice };
|
||||
if (has_warn) return .{ .warn = slice };
|
||||
return .pass;
|
||||
}
|
||||
|
||||
fn checkVolOutlier(ctx: CheckCtx) CheckResult {
|
||||
const port_vol = ctx.totals.vol_3y orelse return .pass; // can't compare without portfolio vol
|
||||
if (port_vol <= 0) return .pass;
|
||||
|
||||
var findings = std.ArrayList(Observation).empty;
|
||||
errdefer {
|
||||
for (findings.items) |o| {
|
||||
ctx.allocator.free(o.kind);
|
||||
ctx.allocator.free(o.target);
|
||||
ctx.allocator.free(o.text);
|
||||
}
|
||||
findings.deinit(ctx.allocator);
|
||||
}
|
||||
var has_flag = false;
|
||||
var has_warn = false;
|
||||
|
||||
for (ctx.rows) |row| {
|
||||
const v = row.vol_3y orelse continue;
|
||||
const ratio = v / port_vol;
|
||||
const sev: ?Severity = if (ratio >= vol_outlier_flag_ratio)
|
||||
.flag
|
||||
else if (ratio >= vol_outlier_warn_ratio)
|
||||
.warn
|
||||
else
|
||||
null;
|
||||
const s = sev orelse continue;
|
||||
const text = std.fmt.allocPrint(ctx.allocator, "{s} 3Y vol {d:.1}% is {d:.1}× portfolio vol ({d:.1}%) (warn at {d:.1}×, flag at {d:.1}×)", .{
|
||||
row.symbol,
|
||||
v * 100.0,
|
||||
ratio,
|
||||
port_vol * 100.0,
|
||||
vol_outlier_warn_ratio,
|
||||
vol_outlier_flag_ratio,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
const target = ctx.allocator.dupe(u8, row.symbol) catch {
|
||||
ctx.allocator.free(text);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
const kind = ctx.allocator.dupe(u8, "vol_outlier") catch {
|
||||
ctx.allocator.free(text);
|
||||
ctx.allocator.free(target);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
findings.append(ctx.allocator, .{
|
||||
.severity = s,
|
||||
.kind = kind,
|
||||
.target = target,
|
||||
.text = text,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
|
||||
}
|
||||
|
||||
if (findings.items.len == 0) return .pass;
|
||||
const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (has_flag) return .{ .flag = slice };
|
||||
if (has_warn) return .{ .warn = slice };
|
||||
return .pass;
|
||||
}
|
||||
|
||||
fn checkTinyPosition(ctx: CheckCtx) CheckResult {
|
||||
if (ctx.rows.len == 0) return .pass;
|
||||
|
||||
var findings = std.ArrayList(Observation).empty;
|
||||
errdefer {
|
||||
for (findings.items) |o| {
|
||||
ctx.allocator.free(o.kind);
|
||||
ctx.allocator.free(o.target);
|
||||
ctx.allocator.free(o.text);
|
||||
}
|
||||
findings.deinit(ctx.allocator);
|
||||
}
|
||||
var has_flag = false;
|
||||
var has_warn = false;
|
||||
|
||||
for (ctx.rows) |row| {
|
||||
const sev: ?Severity = if (row.weight <= tiny_flag_weight)
|
||||
.flag
|
||||
else if (row.weight <= tiny_warn_weight)
|
||||
.warn
|
||||
else
|
||||
null;
|
||||
const s = sev orelse continue;
|
||||
const text = std.fmt.allocPrint(ctx.allocator, "{s} at {d:.2}% of liquid (warn ≤ {d:.2}%, flag ≤ {d:.2}%) — consider consolidating or exiting", .{
|
||||
row.symbol,
|
||||
row.weight * 100.0,
|
||||
tiny_warn_weight * 100.0,
|
||||
tiny_flag_weight * 100.0,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
const target = ctx.allocator.dupe(u8, row.symbol) catch {
|
||||
ctx.allocator.free(text);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
const kind = ctx.allocator.dupe(u8, "tiny_position") catch {
|
||||
ctx.allocator.free(text);
|
||||
ctx.allocator.free(target);
|
||||
return errResult(ctx.allocator, "alloc failed");
|
||||
};
|
||||
findings.append(ctx.allocator, .{
|
||||
.severity = s,
|
||||
.kind = kind,
|
||||
.target = target,
|
||||
.text = text,
|
||||
}) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (s == .flag) has_flag = true else if (s == .warn) has_warn = true;
|
||||
}
|
||||
|
||||
if (findings.items.len == 0) return .pass;
|
||||
const slice = findings.toOwnedSlice(ctx.allocator) catch return errResult(ctx.allocator, "alloc failed");
|
||||
if (has_flag) return .{ .flag = slice };
|
||||
if (has_warn) return .{ .warn = slice };
|
||||
return .pass;
|
||||
}
|
||||
|
||||
/// Drift since last view. Currently a placeholder — returns `skipped`
|
||||
/// until temporal observations ship in a follow-up. The forward-compat
|
||||
/// slot in the status grid stays visible (rendered as ➖) so users
|
||||
/// know the check exists; the engine just never fires it.
|
||||
fn checkDrift(ctx: CheckCtx) CheckResult {
|
||||
_ = ctx;
|
||||
return .skipped;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn makeRow(symbol: []const u8, sector: []const u8, weight: f64) review_view.ReviewRow {
|
||||
return .{
|
||||
.symbol = symbol,
|
||||
.sector_mid = sector,
|
||||
.tax_pct = null,
|
||||
.weight = weight,
|
||||
.return_1y = null,
|
||||
.return_3y = null,
|
||||
.return_5y = null,
|
||||
.return_10y = null,
|
||||
.vol_3y = null,
|
||||
.vol_10y = null,
|
||||
.sharpe_3y = null,
|
||||
.sharpe_10y = null,
|
||||
.maxdd_5y = null,
|
||||
};
|
||||
}
|
||||
|
||||
fn makeRowWithVolAndSharpe(symbol: []const u8, sector: []const u8, weight: f64, vol: f64, sharpe: f64) review_view.ReviewRow {
|
||||
var r = makeRow(symbol, sector, weight);
|
||||
r.vol_3y = vol;
|
||||
r.sharpe_3y = sharpe;
|
||||
return r;
|
||||
}
|
||||
|
||||
fn emptyTotals() review_view.ReviewTotals {
|
||||
return .{
|
||||
.weight = 1.0,
|
||||
.return_1y = null,
|
||||
.return_3y = null,
|
||||
.return_5y = null,
|
||||
.return_10y = null,
|
||||
.vol_3y = null,
|
||||
.vol_10y = null,
|
||||
.sharpe_3y = null,
|
||||
.sharpe_10y = null,
|
||||
.maxdd_5y = null,
|
||||
.tax_pct = null,
|
||||
.reweight_flags = .{},
|
||||
};
|
||||
}
|
||||
|
||||
test "checkPositionConcentration: balanced portfolio passes" {
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRow("A", "X", 0.10),
|
||||
makeRow("B", "Y", 0.10),
|
||||
makeRow("C", "Z", 0.10),
|
||||
makeRow("D", "W", 0.10),
|
||||
makeRow("E", "V", 0.10),
|
||||
makeRow("F", "U", 0.10),
|
||||
makeRow("G", "T", 0.10),
|
||||
makeRow("H", "S", 0.10),
|
||||
makeRow("I", "R", 0.10),
|
||||
makeRow("J", "Q", 0.10),
|
||||
};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkPositionConcentration(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result);
|
||||
}
|
||||
|
||||
test "checkPositionConcentration: large position flags with 27 holdings" {
|
||||
var rows: [27]review_view.ReviewRow = undefined;
|
||||
for (0..27) |i| {
|
||||
rows[i] = makeRow("X", "S", 0.03); // ~equal_weight (1/27 ≈ 0.037)
|
||||
}
|
||||
rows[0] = makeRow("BIG", "S", 0.30); // 30%, well over flag threshold
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkPositionConcentration(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
switch (result) {
|
||||
.flag => |obs| {
|
||||
try testing.expect(obs.len >= 1);
|
||||
// First flagged finding should be the BIG position.
|
||||
var found = false;
|
||||
for (obs) |o| {
|
||||
if (std.mem.eql(u8, o.target, "BIG") and o.severity == .flag) found = true;
|
||||
}
|
||||
try testing.expect(found);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "checkPositionConcentration: small portfolio uses cap" {
|
||||
// 4 positions, equal_weight = 25%. Multiplier × eq = 100% (warn), 150% (flag).
|
||||
// Both clamp at cap (50% warn, 70% flag). A 60% holding flags but a 40% doesn't.
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRow("A", "X", 0.60), // flags (over cap 50% warn, 70% flag — 60% is flag)
|
||||
makeRow("B", "Y", 0.20),
|
||||
makeRow("C", "Z", 0.10),
|
||||
makeRow("D", "W", 0.10),
|
||||
};
|
||||
// Wait: 60% > flag cap 70%? No, 60 < 70. So it should warn, not flag.
|
||||
// Adjust to 75% to actually flag.
|
||||
rows[0].weight = 0.75;
|
||||
rows[1].weight = 0.10;
|
||||
rows[2].weight = 0.10;
|
||||
rows[3].weight = 0.05;
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkPositionConcentration(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
switch (result) {
|
||||
.flag => |obs| try testing.expect(obs.len >= 1),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "checkSectorConcentration: dominant sector flags" {
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRow("A", "Tech", 0.40),
|
||||
makeRow("B", "Tech", 0.35),
|
||||
makeRow("C", "Bonds", 0.10),
|
||||
makeRow("D", "Cash", 0.10),
|
||||
makeRow("E", "Energy", 0.05),
|
||||
};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkSectorConcentration(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
// Tech at 0.75 → flag (5 sectors → flag_thresh = clamp(0.80, 0.30, 0.75) = 0.75).
|
||||
switch (result) {
|
||||
.flag => |obs| {
|
||||
try testing.expect(obs.len >= 1);
|
||||
var tech_found = false;
|
||||
for (obs) |o| {
|
||||
if (std.mem.indexOf(u8, o.text, "Tech") != null) tech_found = true;
|
||||
}
|
||||
try testing.expect(tech_found);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "checkSectorDominance: pair with large Sharpe spread flags" {
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRowWithVolAndSharpe("VTI", "Equity", 0.30, 0.16, 1.20),
|
||||
makeRowWithVolAndSharpe("XLK", "Equity", 0.20, 0.20, 0.30),
|
||||
makeRowWithVolAndSharpe("BND", "Bonds", 0.30, 0.05, 0.40),
|
||||
makeRowWithVolAndSharpe("CASH", "Cash", 0.20, 0.01, 0.10),
|
||||
};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkSectorDominance(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
switch (result) {
|
||||
.flag => |obs| {
|
||||
try testing.expect(obs.len >= 1);
|
||||
// The dominant pair is VTI vs XLK (both Equity, spread 0.9).
|
||||
var pair_found = false;
|
||||
for (obs) |o| {
|
||||
if (std.mem.indexOf(u8, o.target, "VTI") != null and std.mem.indexOf(u8, o.target, "XLK") != null) {
|
||||
pair_found = true;
|
||||
}
|
||||
}
|
||||
try testing.expect(pair_found);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "checkSectorDominance: tiny holding doesn't trigger pair (min_weight filter)" {
|
||||
// VTI is meaningful at 30%; the second equity holding is tiny
|
||||
// (0.5%) so should be filtered out by the min-weight check.
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRowWithVolAndSharpe("VTI", "Equity", 0.30, 0.16, 1.20),
|
||||
makeRowWithVolAndSharpe("PINK", "Equity", 0.005, 0.40, 0.10), // tiny
|
||||
makeRowWithVolAndSharpe("BND", "Bonds", 0.345, 0.05, 0.40),
|
||||
makeRowWithVolAndSharpe("CASH", "Cash", 0.350, 0.01, 0.10),
|
||||
};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkSectorDominance(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result);
|
||||
}
|
||||
|
||||
test "checkVolOutlier: holding with 3× portfolio vol flags" {
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRowWithVolAndSharpe("VTI", "Equity", 0.40, 0.15, 1.0),
|
||||
makeRowWithVolAndSharpe("WILD", "Equity", 0.20, 0.50, 0.5), // 3.3× of portfolio
|
||||
makeRowWithVolAndSharpe("BND", "Bonds", 0.40, 0.05, 0.3),
|
||||
};
|
||||
var totals = emptyTotals();
|
||||
totals.vol_3y = 0.15;
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = totals,
|
||||
};
|
||||
const result = checkVolOutlier(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
switch (result) {
|
||||
.flag => |obs| {
|
||||
try testing.expect(obs.len >= 1);
|
||||
var wild_found = false;
|
||||
for (obs) |o| if (std.mem.eql(u8, o.target, "WILD")) {
|
||||
wild_found = true;
|
||||
};
|
||||
try testing.expect(wild_found);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "checkVolOutlier: passes when totals.vol_3y is null" {
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRowWithVolAndSharpe("WILD", "Equity", 0.20, 0.50, 0.5),
|
||||
};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkVolOutlier(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
try testing.expectEqual(@as(std.meta.Tag(CheckResult), .pass), result);
|
||||
}
|
||||
|
||||
test "checkTinyPosition: positions below thresholds flag" {
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRow("LARGE", "X", 0.30),
|
||||
makeRow("SMALL", "Y", 0.003), // 0.3% — under flag threshold (0.25%) ❌ NO, 0.3% > 0.25% so warns
|
||||
makeRow("TINY", "Z", 0.002), // 0.2% — under flag threshold (0.25%) ✅ flags
|
||||
};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkTinyPosition(ctx);
|
||||
defer freeResult(testing.allocator, result);
|
||||
switch (result) {
|
||||
.flag => |obs| {
|
||||
try testing.expect(obs.len >= 1);
|
||||
var tiny_found = false;
|
||||
for (obs) |o| if (std.mem.eql(u8, o.target, "TINY")) {
|
||||
tiny_found = true;
|
||||
};
|
||||
try testing.expect(tiny_found);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "checkDrift: returns skipped (placeholder)" {
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &.{},
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
const result = checkDrift(ctx);
|
||||
try testing.expectEqual(@as(std.meta.Tag(CheckResult), .skipped), result);
|
||||
}
|
||||
|
||||
test "runChecks: produces a panel with one entry per check" {
|
||||
var rows = [_]review_view.ReviewRow{
|
||||
makeRow("A", "X", 0.50),
|
||||
};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &rows,
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
||||
defer panel.deinit();
|
||||
try testing.expectEqual(default_checks.len, panel.pending.len);
|
||||
try testing.expect(panel.isComplete());
|
||||
}
|
||||
|
||||
test "runChecks: empty portfolio every check passes or skips" {
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &.{},
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
||||
defer panel.deinit();
|
||||
for (panel.pending) |pc| {
|
||||
const tag = std.meta.activeTag(pc.state.complete);
|
||||
try testing.expect(tag == .pass or tag == .skipped);
|
||||
}
|
||||
}
|
||||
|
||||
test "default_checks: every check name is unique" {
|
||||
for (default_checks, 0..) |a, i| {
|
||||
for (default_checks[i + 1 ..]) |b| {
|
||||
try testing.expect(!std.mem.eql(u8, a.name, b.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
708
src/data/journal.zig
Normal file
708
src/data/journal.zig
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
//! Journal of acknowledged observations: `acknowledgments.srf`.
|
||||
//!
|
||||
//! The observation engine produces findings ("Position concentration: NVDA
|
||||
//! at 18.2%") on every run. The user can acknowledge a finding via the
|
||||
//! review tab to record reasoning ("Holding through earnings cycle") and
|
||||
//! suppress it from the active findings list. The journal is the durable
|
||||
//! record of those acknowledgments.
|
||||
//!
|
||||
//! ## File format
|
||||
//!
|
||||
//! Compact-form SRF, discriminated union by `type::`. Records have a
|
||||
//! positional relationship: each `type::note` record attaches to the
|
||||
//! most-recently-seen `type::acknowledgment`.
|
||||
//!
|
||||
//! ### `type::acknowledgment`
|
||||
//!
|
||||
//! - `observation::` — check name, e.g. `position_concentration`.
|
||||
//! - `target::` — per-check string convention. `"NVDA"` for single-symbol
|
||||
//! observations; `"sector:Technology"` for sector-scoped; `"VTI,SCHD"`
|
||||
//! for pair-based observations like sector dominance.
|
||||
//! - `acknowledged_at::` — date the user first acked. Immutable after
|
||||
//! creation.
|
||||
//! - `state::` — `active` | `acknowledged` | `resolved`.
|
||||
//! - `unacknowledged_at::` — info-only breadcrumb, set when the user
|
||||
//! most recently un-acked. Persists across re-acks.
|
||||
//! - `resolved_at::` — info-only, set when the engine auto-resolves.
|
||||
//!
|
||||
//! Each ack is uniquely identified by `(observation, target)`. There is
|
||||
//! never more than one entry per pair — `setState` mutates in place; we
|
||||
//! don't preserve transition history (git tracks that on the file).
|
||||
//!
|
||||
//! ### `type::note`
|
||||
//!
|
||||
//! Zero or more per ack. One field:
|
||||
//!
|
||||
//! - `line::` — single-line content. Multi-line notes are written as N
|
||||
//! consecutive note records following the ack.
|
||||
//!
|
||||
//! Notes are positional: a note record attaches to the most-recent
|
||||
//! preceding ack record. A note record before any ack is a hard parse
|
||||
//! error (`error.OrphanedNote`). The visual layout in the file
|
||||
//! (ack followed by indented-feeling note records) makes the
|
||||
//! relationship obvious to a human reader.
|
||||
//!
|
||||
//! Example:
|
||||
//!
|
||||
//! ```
|
||||
//! #!srfv1
|
||||
//! type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-08,state::acknowledged
|
||||
//! type::note,line::Holding through earnings cycle.
|
||||
//! type::note,line::Will trim by Q3 2026.
|
||||
//! type::acknowledgment,observation::sector_dominance,target::VTI,SCHD,acknowledged_at::2026-06-08,state::acknowledged
|
||||
//! ```
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! - **Read:** single-pass iterator over the file. Acks push a new
|
||||
//! `JournalEntry`; notes append to the last entry. Orphan note ⇒
|
||||
//! `error.OrphanedNote`.
|
||||
//! - **Write:** `append` / `setState` mutate the in-memory `entries` and
|
||||
//! atomic-rewrite the file (temp file + rename). Always rewriting keeps
|
||||
//! the file clean; git tracks history.
|
||||
//! - **Concurrency:** the file is per-portfolio (sibling of
|
||||
//! `portfolio.srf`); concurrent zfin invocations on the same portfolio
|
||||
//! would race, but that's the existing convention for every sibling
|
||||
//! file in this codebase.
|
||||
|
||||
const std = @import("std");
|
||||
const srf = @import("srf");
|
||||
const Date = @import("../Date.zig");
|
||||
const atomic = @import("../atomic.zig");
|
||||
|
||||
pub const State = enum {
|
||||
active,
|
||||
acknowledged,
|
||||
resolved,
|
||||
};
|
||||
|
||||
/// One acknowledgment record. Uniquely identified by
|
||||
/// `(observation, target)`.
|
||||
pub const Acknowledgment = struct {
|
||||
observation: []const u8,
|
||||
target: []const u8,
|
||||
acknowledged_at: Date,
|
||||
state: State,
|
||||
unacknowledged_at: ?Date = null,
|
||||
resolved_at: ?Date = null,
|
||||
};
|
||||
|
||||
/// Wire-format note record: just a single line of text. Used only by
|
||||
/// the SRF parser/formatter; the rest of the codebase sees notes as
|
||||
/// `[]const u8` slices on `JournalEntry.notes`.
|
||||
const NoteRecord = struct {
|
||||
line: []const u8,
|
||||
};
|
||||
|
||||
/// Discriminated-union over the two SRF record types in the journal.
|
||||
/// SRF dispatches on the `type` tag field by convention. Internal to
|
||||
/// the parser/formatter.
|
||||
const JournalRecord = union(enum) {
|
||||
acknowledgment: Acknowledgment,
|
||||
note: NoteRecord,
|
||||
};
|
||||
|
||||
/// In-memory ack with its notes already grouped. Built by
|
||||
/// `Journal.load`; consumed by callers that want "the ack and its
|
||||
/// reasoning together."
|
||||
pub const JournalEntry = struct {
|
||||
ack: Acknowledgment,
|
||||
/// Note fragments in the order the user entered them. Each is an
|
||||
/// allocator-owned slice; freed by `Journal.deinit`.
|
||||
notes: []const []const u8,
|
||||
|
||||
/// Concatenate the notes with newlines into a single string.
|
||||
/// Allocator-owned; caller frees.
|
||||
pub fn fullNote(self: JournalEntry, allocator: std.mem.Allocator) ![]u8 {
|
||||
return try std.mem.join(allocator, "\n", self.notes);
|
||||
}
|
||||
};
|
||||
|
||||
/// In-memory journal state. Owns all string data and the entries slice.
|
||||
pub const Journal = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
entries: []JournalEntry,
|
||||
|
||||
pub fn deinit(self: *Journal) void {
|
||||
const a = self.allocator;
|
||||
for (self.entries) |entry| {
|
||||
a.free(entry.ack.observation);
|
||||
a.free(entry.ack.target);
|
||||
for (entry.notes) |line| a.free(line);
|
||||
a.free(entry.notes);
|
||||
}
|
||||
a.free(self.entries);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Find the entry matching `(observation, target)`. Returns null
|
||||
/// if not found. There's only ever one entry per pair (we don't
|
||||
/// preserve transition history), so no tiebreaker is needed.
|
||||
pub fn findByTarget(
|
||||
self: *const Journal,
|
||||
observation: []const u8,
|
||||
target: []const u8,
|
||||
) ?*const JournalEntry {
|
||||
for (self.entries) |*e| {
|
||||
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
|
||||
if (!std.mem.eql(u8, e.ack.target, target)) continue;
|
||||
return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Load and parse a journal file. Returns an empty `Journal` when the
|
||||
/// file doesn't exist (first-time use case). `path` should be an
|
||||
/// absolute or cwd-relative path to `acknowledgments.srf`.
|
||||
pub fn load(
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
path: []const u8,
|
||||
) !Journal {
|
||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) {
|
||||
error.FileNotFound => {
|
||||
return .{ .allocator = allocator, .entries = try allocator.alloc(JournalEntry, 0) };
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
defer allocator.free(file_data);
|
||||
return try parse(allocator, file_data);
|
||||
}
|
||||
|
||||
/// Parse pre-read file bytes into a `Journal`. Used by `load` and by
|
||||
/// tests that supply synthetic data.
|
||||
///
|
||||
/// Walks records in a single pass. Each `type::acknowledgment`
|
||||
/// pushes a new entry with empty notes. Each `type::note` appends
|
||||
/// to the most-recent entry's notes. A note before any ack
|
||||
/// returns `error.OrphanedNote`.
|
||||
///
|
||||
/// **Strict**: any record that fails to deserialize (missing
|
||||
/// required field, unknown enum variant, garbage bytes) propagates
|
||||
/// the error out of `parse`. We don't silently skip — a malformed
|
||||
/// record means user-visible data loss (acks suppress findings;
|
||||
/// dropping an ack pops a finding back into the active list with
|
||||
/// no explanation). Better to fail loud at load time so the user
|
||||
/// can fix the file.
|
||||
pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
|
||||
// Empty input ⇒ empty journal. `srf.iterator` requires a version
|
||||
// banner on the first line and errors out otherwise; short-circuit.
|
||||
if (data.len == 0) {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.entries = try allocator.alloc(JournalEntry, 0),
|
||||
};
|
||||
}
|
||||
|
||||
var entries = std.ArrayList(JournalEntry).empty;
|
||||
errdefer freeEntries(allocator, &entries);
|
||||
|
||||
// Per-entry notes lists. Lives parallel to `entries` and is
|
||||
// converted to owned slices at the end. We use a separate list
|
||||
// (instead of mutating each entry's `notes` field as we go)
|
||||
// because `JournalEntry.notes` is `[]const []const u8` — a const
|
||||
// slice — so we can't append to it after the entry is created.
|
||||
var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty;
|
||||
errdefer {
|
||||
for (notes_per_entry.items) |*notes| {
|
||||
for (notes.items) |line| allocator.free(line);
|
||||
notes.deinit(allocator);
|
||||
}
|
||||
notes_per_entry.deinit(allocator);
|
||||
}
|
||||
|
||||
var reader = std.Io.Reader.fixed(data);
|
||||
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData;
|
||||
defer it.deinit();
|
||||
|
||||
while (try it.next()) |fields| {
|
||||
const rec = try fields.to(JournalRecord, .{});
|
||||
switch (rec) {
|
||||
.acknowledgment => |a| {
|
||||
try entries.append(allocator, .{
|
||||
.ack = .{
|
||||
.observation = try allocator.dupe(u8, a.observation),
|
||||
.target = try allocator.dupe(u8, a.target),
|
||||
.acknowledged_at = a.acknowledged_at,
|
||||
.state = a.state,
|
||||
.unacknowledged_at = a.unacknowledged_at,
|
||||
.resolved_at = a.resolved_at,
|
||||
},
|
||||
.notes = &.{}, // placeholder; replaced below
|
||||
});
|
||||
try notes_per_entry.append(allocator, std.ArrayList([]const u8).empty);
|
||||
},
|
||||
.note => |n| {
|
||||
if (entries.items.len == 0) return error.OrphanedNote;
|
||||
const last_notes = ¬es_per_entry.items[notes_per_entry.items.len - 1];
|
||||
try last_notes.append(allocator, try allocator.dupe(u8, n.line));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Move notes lists into their entries' `notes` fields as owned
|
||||
// slices. After this each entry owns its own notes; the
|
||||
// outer `notes_per_entry` list is empty.
|
||||
for (entries.items, 0..) |*entry, i| {
|
||||
entry.notes = try notes_per_entry.items[i].toOwnedSlice(allocator);
|
||||
}
|
||||
notes_per_entry.deinit(allocator);
|
||||
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.entries = try entries.toOwnedSlice(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
/// Free a partially-built entries list (for parse errdefer).
|
||||
fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(JournalEntry)) void {
|
||||
for (entries.items) |entry| {
|
||||
allocator.free(entry.ack.observation);
|
||||
allocator.free(entry.ack.target);
|
||||
for (entry.notes) |line| allocator.free(line);
|
||||
allocator.free(entry.notes);
|
||||
}
|
||||
entries.deinit(allocator);
|
||||
}
|
||||
|
||||
/// Append a new acknowledgment with notes, then atomic-rewrite the
|
||||
/// file. The journal's in-memory state is updated to reflect the new
|
||||
/// entry; caller owns the journal as before.
|
||||
///
|
||||
/// `note_fragments` is the list of single-line strings the user
|
||||
/// entered (one Enter press per fragment in the TUI's note input).
|
||||
/// Pass an empty slice to record an ack with no reasoning.
|
||||
pub fn append(
|
||||
journal: *Journal,
|
||||
io: std.Io,
|
||||
path: []const u8,
|
||||
new_ack: Acknowledgment,
|
||||
note_fragments: []const []const u8,
|
||||
) !void {
|
||||
const a = journal.allocator;
|
||||
|
||||
// Build the new entry's owned strings first. errdefer cleanup
|
||||
// is fiddly because we need to roll back partial allocations
|
||||
// on any failure; doing it in stages keeps the pattern clear.
|
||||
const owned_obs = try a.dupe(u8, new_ack.observation);
|
||||
errdefer a.free(owned_obs);
|
||||
const owned_target = try a.dupe(u8, new_ack.target);
|
||||
errdefer a.free(owned_target);
|
||||
|
||||
var owned_notes = try a.alloc([]const u8, note_fragments.len);
|
||||
errdefer a.free(owned_notes);
|
||||
var note_idx: usize = 0;
|
||||
errdefer for (owned_notes[0..note_idx]) |line| a.free(line);
|
||||
for (note_fragments, 0..) |frag, i| {
|
||||
owned_notes[i] = try a.dupe(u8, frag);
|
||||
note_idx = i + 1;
|
||||
}
|
||||
|
||||
// Grow the entries slice by one and append.
|
||||
var new_entries = try a.alloc(JournalEntry, journal.entries.len + 1);
|
||||
errdefer a.free(new_entries);
|
||||
@memcpy(new_entries[0..journal.entries.len], journal.entries);
|
||||
new_entries[journal.entries.len] = .{
|
||||
.ack = .{
|
||||
.observation = owned_obs,
|
||||
.target = owned_target,
|
||||
.acknowledged_at = new_ack.acknowledged_at,
|
||||
.state = new_ack.state,
|
||||
.unacknowledged_at = new_ack.unacknowledged_at,
|
||||
.resolved_at = new_ack.resolved_at,
|
||||
},
|
||||
.notes = owned_notes,
|
||||
};
|
||||
|
||||
// Replace the slice WITHOUT freeing the old strings — they're
|
||||
// shallow-copied into new_entries above. Just free the old slice.
|
||||
a.free(journal.entries);
|
||||
journal.entries = new_entries;
|
||||
|
||||
try writeFile(journal, io, path);
|
||||
}
|
||||
|
||||
/// Update the state of an existing acknowledgment, set the relevant
|
||||
/// breadcrumb timestamp, and atomic-rewrite the file. The state
|
||||
/// transition machine:
|
||||
///
|
||||
/// - `active → acknowledged` — clears `unacknowledged_at`.
|
||||
/// - `acknowledged → active` — sets `unacknowledged_at = today`.
|
||||
/// - `* → resolved` — sets `resolved_at = today`.
|
||||
/// - `resolved → active` — clears `resolved_at`.
|
||||
///
|
||||
/// Returns `error.AckNotFound` if no entry matches `(observation,
|
||||
/// target)`.
|
||||
pub fn setState(
|
||||
journal: *Journal,
|
||||
io: std.Io,
|
||||
path: []const u8,
|
||||
observation: []const u8,
|
||||
target: []const u8,
|
||||
new_state: State,
|
||||
today: Date,
|
||||
) !void {
|
||||
var found: ?*JournalEntry = null;
|
||||
for (journal.entries) |*e| {
|
||||
if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
|
||||
if (!std.mem.eql(u8, e.ack.target, target)) continue;
|
||||
found = e;
|
||||
break;
|
||||
}
|
||||
const entry = found orelse return error.AckNotFound;
|
||||
|
||||
switch (new_state) {
|
||||
.acknowledged => {
|
||||
entry.ack.unacknowledged_at = null;
|
||||
},
|
||||
.active => {
|
||||
if (entry.ack.state == .acknowledged) entry.ack.unacknowledged_at = today;
|
||||
if (entry.ack.state == .resolved) entry.ack.resolved_at = null;
|
||||
},
|
||||
.resolved => {
|
||||
entry.ack.resolved_at = today;
|
||||
},
|
||||
}
|
||||
entry.ack.state = new_state;
|
||||
|
||||
try writeFile(journal, io, path);
|
||||
}
|
||||
|
||||
/// Atomic file write: serialize all records, then hand to
|
||||
/// `atomic.writeFileAtomic` which writes to `<path>.tmp`, fsyncs,
|
||||
/// and renames. Crash-safe.
|
||||
fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void {
|
||||
const a = journal.allocator;
|
||||
|
||||
// Build the file contents in memory. For a journal of typical
|
||||
// size (dozens to low hundreds of records) this is trivially
|
||||
// small; if it grows large we revisit streaming.
|
||||
var buf: std.Io.Writer.Allocating = .init(a);
|
||||
defer buf.deinit();
|
||||
|
||||
// Flatten into a single records slice so `srf.fmt` writes the
|
||||
// `#!srfv1` directive header once at the top and emits every
|
||||
// record through its native formatter. Ack records are followed
|
||||
// immediately by their note records, in entry-list order.
|
||||
var total_records: usize = journal.entries.len;
|
||||
for (journal.entries) |e| total_records += e.notes.len;
|
||||
var records = try a.alloc(JournalRecord, total_records);
|
||||
defer a.free(records);
|
||||
var ri: usize = 0;
|
||||
for (journal.entries) |e| {
|
||||
records[ri] = .{ .acknowledgment = e.ack };
|
||||
ri += 1;
|
||||
for (e.notes) |line| {
|
||||
records[ri] = .{ .note = .{ .line = line } };
|
||||
ri += 1;
|
||||
}
|
||||
}
|
||||
|
||||
try buf.writer.print("{f}", .{srf.fmt(JournalRecord, records, .{})});
|
||||
|
||||
try atomic.writeFileAtomic(io, a, path, buf.writer.buffered());
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "parse: empty input produces empty journal" {
|
||||
var j = try parse(testing.allocator, "");
|
||||
defer j.deinit();
|
||||
try testing.expectEqual(@as(usize, 0), j.entries.len);
|
||||
}
|
||||
|
||||
test "parse: single ack with two notes round-trips" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged
|
||||
\\type::note,line::Holding through earnings cycle.
|
||||
\\type::note,line::Will trim by Q3 2026.
|
||||
;
|
||||
var j = try parse(testing.allocator, data);
|
||||
defer j.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), j.entries.len);
|
||||
const entry = j.entries[0];
|
||||
try testing.expectEqualStrings("position_concentration", entry.ack.observation);
|
||||
try testing.expectEqualStrings("NVDA", entry.ack.target);
|
||||
try testing.expectEqual(State.acknowledged, entry.ack.state);
|
||||
try testing.expectEqual(@as(usize, 2), entry.notes.len);
|
||||
try testing.expectEqualStrings("Holding through earnings cycle.", entry.notes[0]);
|
||||
try testing.expectEqualStrings("Will trim by Q3 2026.", entry.notes[1]);
|
||||
}
|
||||
|
||||
test "parse: notes attach to the most-recent preceding ack" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::p,target::A,acknowledged_at::2026-06-12,state::active
|
||||
\\type::note,line::for A
|
||||
\\type::acknowledgment,observation::p,target::B,acknowledged_at::2026-06-13,state::active
|
||||
\\type::note,line::for B
|
||||
\\type::note,line::also for B
|
||||
;
|
||||
var j = try parse(testing.allocator, data);
|
||||
defer j.deinit();
|
||||
try testing.expectEqual(@as(usize, 2), j.entries.len);
|
||||
try testing.expectEqual(@as(usize, 1), j.entries[0].notes.len);
|
||||
try testing.expectEqualStrings("for A", j.entries[0].notes[0]);
|
||||
try testing.expectEqual(@as(usize, 2), j.entries[1].notes.len);
|
||||
try testing.expectEqualStrings("for B", j.entries[1].notes[0]);
|
||||
try testing.expectEqualStrings("also for B", j.entries[1].notes[1]);
|
||||
}
|
||||
|
||||
test "parse: orphan note before any ack returns error.OrphanedNote" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::note,line::orphaned
|
||||
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
|
||||
;
|
||||
try testing.expectError(error.OrphanedNote, parse(testing.allocator, data));
|
||||
}
|
||||
|
||||
test "findByTarget: finds matching, returns null for non-match" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged
|
||||
\\type::acknowledgment,observation::sector_concentration,target::sector:Technology,acknowledged_at::2026-06-13,state::acknowledged
|
||||
;
|
||||
var j = try parse(testing.allocator, data);
|
||||
defer j.deinit();
|
||||
|
||||
const found = j.findByTarget("position_concentration", "NVDA").?;
|
||||
try testing.expectEqualStrings("NVDA", found.ack.target);
|
||||
|
||||
const not_found = j.findByTarget("position_concentration", "AAPL");
|
||||
try testing.expect(not_found == null);
|
||||
}
|
||||
|
||||
test "JournalEntry.fullNote: joins fragments with newlines" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
|
||||
\\type::note,line::first
|
||||
\\type::note,line::second
|
||||
\\type::note,line::third
|
||||
;
|
||||
var j = try parse(testing.allocator, data);
|
||||
defer j.deinit();
|
||||
const full = try j.entries[0].fullNote(testing.allocator);
|
||||
defer testing.allocator.free(full);
|
||||
try testing.expectEqualStrings("first\nsecond\nthird", full);
|
||||
}
|
||||
|
||||
test "JournalEntry.fullNote: empty notes returns empty string" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
|
||||
;
|
||||
var j = try parse(testing.allocator, data);
|
||||
defer j.deinit();
|
||||
const full = try j.entries[0].fullNote(testing.allocator);
|
||||
defer testing.allocator.free(full);
|
||||
try testing.expectEqualStrings("", full);
|
||||
}
|
||||
|
||||
test "parse: optional unacknowledged_at and resolved_at fields work when omitted" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged
|
||||
;
|
||||
var j = try parse(testing.allocator, data);
|
||||
defer j.deinit();
|
||||
try testing.expect(j.entries[0].ack.unacknowledged_at == null);
|
||||
try testing.expect(j.entries[0].ack.resolved_at == null);
|
||||
}
|
||||
|
||||
test "parse: optional unacknowledged_at and resolved_at fields work when set" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active,unacknowledged_at::2026-08-01,resolved_at::2026-12-01
|
||||
;
|
||||
var j = try parse(testing.allocator, data);
|
||||
defer j.deinit();
|
||||
try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, j.entries[0].ack.unacknowledged_at.?.days);
|
||||
try testing.expectEqual(Date.fromYmd(2026, 12, 1).days, j.entries[0].ack.resolved_at.?.days);
|
||||
}
|
||||
|
||||
test "parse: malformed record returns parse error" {
|
||||
// A record with no `type::` discriminator should fail SRF
|
||||
// deserialization (ActiveTagNotFirstField), and we propagate
|
||||
// the error rather than silently skipping. The exact error
|
||||
// variant is SRF's choice; we just assert that an error is
|
||||
// returned.
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged
|
||||
\\garbage_field::nope
|
||||
\\type::acknowledgment,observation::q,target::U,acknowledged_at::2026-06-13,state::active
|
||||
;
|
||||
try testing.expectError(error.ActiveTagNotFirstField, parse(testing.allocator, data));
|
||||
}
|
||||
|
||||
// ── I/O tests (load + append + setState round-trips) ──────────
|
||||
|
||||
test "load: missing file returns empty journal" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const path = try std.fmt.allocPrint(allocator, "{s}/does_not_exist.srf", .{dir_path});
|
||||
defer allocator.free(path);
|
||||
|
||||
var j = try load(allocator, io, path);
|
||||
defer j.deinit();
|
||||
try testing.expectEqual(@as(usize, 0), j.entries.len);
|
||||
}
|
||||
|
||||
test "append + load round-trip: ack with two notes" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
||||
defer allocator.free(path);
|
||||
|
||||
var j = try load(allocator, io, path);
|
||||
defer j.deinit();
|
||||
try testing.expectEqual(@as(usize, 0), j.entries.len);
|
||||
|
||||
const fragments = [_][]const u8{ "first thought", "follow-up rationale" };
|
||||
try append(&j, io, path, .{
|
||||
.observation = "position_concentration",
|
||||
.target = "NVDA",
|
||||
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
||||
.state = .acknowledged,
|
||||
}, &fragments);
|
||||
|
||||
var j2 = try load(allocator, io, path);
|
||||
defer j2.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), j2.entries.len);
|
||||
try testing.expectEqualStrings("position_concentration", j2.entries[0].ack.observation);
|
||||
try testing.expectEqualStrings("NVDA", j2.entries[0].ack.target);
|
||||
try testing.expectEqual(State.acknowledged, j2.entries[0].ack.state);
|
||||
try testing.expectEqual(@as(usize, 2), j2.entries[0].notes.len);
|
||||
try testing.expectEqualStrings("first thought", j2.entries[0].notes[0]);
|
||||
try testing.expectEqualStrings("follow-up rationale", j2.entries[0].notes[1]);
|
||||
}
|
||||
|
||||
test "append: two acks land in append-order on reload" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
||||
defer allocator.free(path);
|
||||
|
||||
var j = try load(allocator, io, path);
|
||||
defer j.deinit();
|
||||
|
||||
try append(&j, io, path, .{
|
||||
.observation = "k",
|
||||
.target = "B",
|
||||
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
||||
.state = .acknowledged,
|
||||
}, &.{});
|
||||
try append(&j, io, path, .{
|
||||
.observation = "k",
|
||||
.target = "A",
|
||||
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
||||
.state = .acknowledged,
|
||||
}, &.{});
|
||||
|
||||
var j2 = try load(allocator, io, path);
|
||||
defer j2.deinit();
|
||||
try testing.expectEqual(@as(usize, 2), j2.entries.len);
|
||||
try testing.expectEqualStrings("B", j2.entries[0].ack.target);
|
||||
try testing.expectEqualStrings("A", j2.entries[1].ack.target);
|
||||
}
|
||||
|
||||
test "setState: acknowledged → active sets unacknowledged_at" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
||||
defer allocator.free(path);
|
||||
|
||||
var j = try load(allocator, io, path);
|
||||
defer j.deinit();
|
||||
|
||||
try append(&j, io, path, .{
|
||||
.observation = "k",
|
||||
.target = "X",
|
||||
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
||||
.state = .acknowledged,
|
||||
}, &.{});
|
||||
|
||||
try setState(&j, io, path, "k", "X", .active, Date.fromYmd(2026, 6, 9));
|
||||
|
||||
var j2 = try load(allocator, io, path);
|
||||
defer j2.deinit();
|
||||
try testing.expectEqual(State.active, j2.entries[0].ack.state);
|
||||
try testing.expect(j2.entries[0].ack.unacknowledged_at != null);
|
||||
try testing.expect(j2.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9)));
|
||||
}
|
||||
|
||||
test "setState: missing target returns AckNotFound" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
||||
defer allocator.free(path);
|
||||
|
||||
var j = try load(allocator, io, path);
|
||||
defer j.deinit();
|
||||
|
||||
try testing.expectError(
|
||||
error.AckNotFound,
|
||||
setState(&j, io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
test "setState: → resolved sets resolved_at" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
|
||||
defer allocator.free(dir_path);
|
||||
|
||||
const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
|
||||
defer allocator.free(path);
|
||||
|
||||
var j = try load(allocator, io, path);
|
||||
defer j.deinit();
|
||||
|
||||
try append(&j, io, path, .{
|
||||
.observation = "k",
|
||||
.target = "X",
|
||||
.acknowledged_at = Date.fromYmd(2026, 6, 8),
|
||||
.state = .acknowledged,
|
||||
}, &.{});
|
||||
|
||||
try setState(&j, io, path, "k", "X", .resolved, Date.fromYmd(2026, 6, 10));
|
||||
|
||||
var j2 = try load(allocator, io, path);
|
||||
defer j2.deinit();
|
||||
try testing.expectEqual(State.resolved, j2.entries[0].ack.state);
|
||||
try testing.expect(j2.entries[0].ack.resolved_at != null);
|
||||
try testing.expect(j2.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10)));
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ const Date = @import("../Date.zig");
|
|||
const risk = @import("../analytics/risk.zig");
|
||||
const shiller = @import("shiller.zig");
|
||||
const review = @import("../views/review.zig");
|
||||
const observations = @import("../analytics/observations.zig");
|
||||
|
||||
/// A hand-maintained data source that nags once a year if it hasn't
|
||||
/// been refreshed by its annual `(due_month, due_day)`.
|
||||
|
|
@ -77,6 +78,13 @@ pub const entries = [_]StaleEntry{
|
|||
.due_day = 1,
|
||||
.source_file = "src/views/review.zig",
|
||||
},
|
||||
.{
|
||||
.name = "Observation engine thresholds",
|
||||
.last_updated = observations.observation_thresholds_last_reviewed,
|
||||
.due_month = 6,
|
||||
.due_day = 1,
|
||||
.source_file = "src/analytics/observations.zig",
|
||||
},
|
||||
};
|
||||
|
||||
/// Write a warning line for each entry in `entries` that is overdue
|
||||
|
|
@ -292,7 +300,7 @@ test "silent when today is one day before due" {
|
|||
test "real registry compiles and is non-empty" {
|
||||
// Guard that the registry stays wired up; doesn't assert any
|
||||
// particular nag behavior (real dates drift over time).
|
||||
try std.testing.expect(entries.len >= 3);
|
||||
try std.testing.expect(entries.len >= 4);
|
||||
for (entries) |e| {
|
||||
try std.testing.expect(e.name.len > 0);
|
||||
try std.testing.expect(e.source_file.len > 0);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue