Skip to content

Commit b536d0b

Browse files
committed
feat(memory): trend history as splayed tables (Layer 3, v0.7.0)
Materialize the .raysense/trends/history.json time-series as three splayed baseline tables (trend_health, trend_hotspots, trend_violations). Schema bumped 3 -> 4. Older v3 baselines need re-save; v1 trend samples remain readable but contribute zero rows to the long tables. history.json gains schema v2: top_hotspots (capped at 20 by risk_score) and rule_breakdown per sample. Old v1 samples deserialize via #[serde(default)] and roll forward on the next baseline save. raysense_trend MCP tool generalized: window (7d|30d|90d|all), dimension (health|hotspots|violations|all), format (summary|table|json), limit (now actually wired). raysense_drift MCP tool added: ranks regressions across the window, returning worsened dimensions, hotspots new or risen, and rules newly tripped or with increased counts. claude-plugin gains drift skill alongside bootstrap/impact/verify/ audit/query. Wraps rescan -> drift -> remediations.
1 parent c3d632a commit b536d0b

10 files changed

Lines changed: 1283 additions & 37 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
CLAUDE.md
22
.claude/
3+
.raysense/
34
/target/
45
Cargo.lock
56
/local/

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
[package]
2323
name = "raysense"
24-
version = "0.6.2"
24+
version = "0.7.0"
2525
edition = "2021"
2626
license = "MIT"
2727
repository = "https://github.com/RayforceDB/raysense"

README.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ Rayfall expressions, drop in custom rules as `.rfl` files, and bring
118118
external CSVs into the same query substrate.
119119

120120
```bash
121-
# 1. Save a baseline with 18 splayed tables (files, functions, calls,
122-
# call_edges, imports, types, hotspots, change_coupling, ...)
121+
# 1. Save a baseline with 21 splayed tables (files, functions, calls,
122+
# call_edges, imports, types, hotspots, change_coupling,
123+
# trend_health, trend_hotspots, trend_violations, ...)
123124
raysense baseline save .
124125

125126
# 2. Ad-hoc query in Rayfall (LISP-like, prefix, arity-strict).
@@ -161,9 +162,10 @@ Raysense ships as a Claude Code plugin:
161162
/plugin install raysense
162163
```
163164

164-
Five skills: scan + baseline at session start, blast radius before
165-
edits, regression diff after, on-demand architecture audits, and a
166-
Rayfall query skill that lets agents run free-form expressions
165+
Six skills: scan + baseline at session start, blast radius before
166+
edits, regression diff after, on-demand architecture audits,
167+
time-window drift detection ("what got worse over the last 30 days"),
168+
and a Rayfall query skill that lets agents run free-form expressions
167169
against the saved baseline tables when the typed tools fall short.
168170
Multi-codebase isolation is cwd-driven, so per-project state stays in
169171
`<repo>/.raysense/`. Two sessions on two repos = two independent
@@ -196,9 +198,15 @@ baselines, zero cross-project bleed.
196198
agent edit is most likely to break. Composite of churn, max
197199
complexity, single-owner penalty, and missing-tests penalty,
198200
refreshed on every save
199-
- **Score drift per session** - every baseline save appends a sample;
200-
verify diffs against the previous one and surfaces per-dimension
201-
drift (Equality went B to D) instead of a single aggregate delta
201+
- **Drift over time** - every baseline save and `trend record`
202+
appends a sample to `.raysense/trends/history.json`. The save
203+
materializes three splayed tables (`trend_health`,
204+
`trend_hotspots`, `trend_violations`) so agents query "what got
205+
worse over the last 30 days" through Rayfall, the typed
206+
`raysense_drift` MCP tool, or the `drift` skill, instead of
207+
parsing history.json themselves. Verify still surfaces
208+
per-dimension regressions against the saved baseline (Equality B
209+
to D) for the no-time-window case
202210
- **Bug-density per file** - files where most of the churn is fix
203211
commits float to the top. Conventional Commits prefixes (fix,
204212
hotfix, revert) drive the classifier; absolute count and ratio

claude-plugin/.claude-plugin/plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "raysense",
3-
"version": "0.3.0",
4-
"description": "Architectural telemetry for AI coding agents. Phase-scoped skills over the raysense MCP server: scan + baseline at session start, blast-radius before edits, regression diff after, on-demand architecture audits.",
3+
"version": "0.4.0",
4+
"description": "Architectural telemetry for AI coding agents. Phase-scoped skills over the raysense MCP server: scan + baseline at session start, blast-radius before edits, regression diff after, on-demand architecture audits, time-window drift detection.",
55
"author": {
66
"name": "Anton Kundenko",
77
"email": "anton.kundenko@gmail.com"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
name: drift
3+
description: Use after a rescan to surface structural regressions across a time window. Diffs the latest scan against the saved baseline AND the trend history, ranking dimensions that worsened, files newly hot, and rules newly tripped. Configurable window (7d, 30d, 90d). Use periodically (daily, weekly, or pre-PR).
4+
---
5+
6+
# Drift
7+
8+
`drift` answers one question: "what got worse since N days ago?"
9+
It is heavier than `verify` (which only diffs against the saved
10+
baseline) but lighter than `audit` (which sweeps the whole repo).
11+
Run it when the question is shaped like "are we slowly losing
12+
modularity?" or "did a bad pattern creep in over the last sprint?"
13+
14+
All tools take a `path` argument; pass the current repo root as an
15+
absolute path.
16+
17+
## Steps
18+
19+
1. **Rescan**. Call `raysense_rescan` with `path: <cwd>` so the
20+
active health is current.
21+
2. **Drift summary**. Call `raysense_drift` with `path: <cwd>` and
22+
`window: 30d` (the default). Returns:
23+
- `worsened_dimensions`: the dimensions whose scores dropped
24+
(or rule count rose) across the window.
25+
- `hotspots_new_or_risen`: files that newly entered the top
26+
hotspots or whose `risk_score` climbed.
27+
- `rules_new_or_increased`: rule codes that newly tripped or
28+
whose violation count grew.
29+
3. **Trend context**. When `drift` reports `available: false`
30+
(fewer than 2 samples in the window), call `raysense_trend` with
31+
`window: all` so the user sees how short the history is. Suggest
32+
they call `raysense_baseline_save` to seed a sample.
33+
4. **Remediations on regression**. For each entry in
34+
`rules_new_or_increased`, call `raysense_remediations` and
35+
surface the suggestion alongside the regression.
36+
37+
## What to surface to the user
38+
39+
A good drift report leads with the worst regression, not the full
40+
list. Three focused lines beat one wall of metrics:
41+
42+
- "Modularity dropped 0.92 to 0.78 (worst dimension this window)."
43+
- "src/big.rs is the new top hotspot (risk_score 50 to 216)."
44+
- "Rule `max_function_complexity` newly tripped (0 to 2)."
45+
46+
If `drift` returns nothing in any of the three categories, say so
47+
plainly: "No drift detected in the last 30d." Do not pad with empty
48+
sections.
49+
50+
## Window choice
51+
52+
- `7d`: catches regressions from the current week's edits.
53+
- `30d`: default. Sees a typical sprint's worth of structural
54+
movement.
55+
- `90d`: quarterly review cadence. Often spans refactors that
56+
haven't fully settled.
57+
- `all`: every recorded sample. Use when you want the full arc.
58+
59+
Drift compares the *oldest* in-window sample to the *newest*
60+
in-window sample. Wider windows give bigger deltas but blur acute
61+
regressions.
62+
63+
## When to skip
64+
65+
- Fewer than 2 samples in the trend history. The skill will return
66+
`available: false` and the report will be empty. Run
67+
`raysense_baseline_save` first to seed history.json.
68+
- The user asked "what's broken right now?" That is a `verify` or
69+
`audit` question, not a drift question.
70+
71+
## See also
72+
73+
- `verify`: snapshot diff against the session baseline (no time
74+
axis). Use after a focused chunk of edits.
75+
- `audit`: whole-repo structural sweep, no time axis. Use when the
76+
question is shape, not change.
77+
- `raysense_baseline_query`: the `query` skill covers Rayfall
78+
directly. The splayed `trend_health`, `trend_hotspots`, and
79+
`trend_violations` tables are queryable from there for custom
80+
drift analyses (per-dimension regressions, file-specific arcs).

src/cli.rs

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use crate::{
3232
use anyhow::{anyhow, Context, Result};
3333
use clap::{Parser, Subcommand};
3434
use serde_json::{json, Value};
35-
use std::collections::BTreeSet;
35+
use std::collections::{BTreeMap, BTreeSet};
3636
use std::fs;
3737
use std::path::{Path, PathBuf};
3838
use std::process;
@@ -2731,11 +2731,21 @@ fn record_trend(root: &Path, config_path: Option<&Path>) -> Result<()> {
27312731
Ok(())
27322732
}
27332733

2734+
/// On-disk schema version for `history.json` samples. Bumped to 2 in
2735+
/// v0.7 to add `top_hotspots` and `rule_breakdown`. Older samples
2736+
/// without the `schema` field deserialize as `0` and are read by
2737+
/// downstream consumers as v1.
2738+
const TREND_SCHEMA_VERSION: u32 = 2;
2739+
2740+
/// How many temporal hotspots to embed per trend sample. Capped to
2741+
/// keep `history.json` from growing unbounded with churny repos.
2742+
const TREND_TOP_HOTSPOTS_LIMIT: usize = 20;
2743+
27342744
/// Append one row to `<root>/.raysense/trends/history.json` describing
27352745
/// the score, quality signal, rule count, and per-dimension scores at
27362746
/// `now`. Used both by `raysense trend record` and by `raysense
27372747
/// baseline save`. Returns the path the row was written to.
2738-
fn append_trend_sample(report: &ScanReport, health: &HealthSummary) -> Result<PathBuf> {
2748+
pub(crate) fn append_trend_sample(report: &ScanReport, health: &HealthSummary) -> Result<PathBuf> {
27392749
let dir = report.snapshot.root.join(".raysense/trends");
27402750
let path = dir.join("history.json");
27412751
fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
@@ -2744,7 +2754,27 @@ fn append_trend_sample(report: &ScanReport, health: &HealthSummary) -> Result<Pa
27442754
} else {
27452755
Vec::new()
27462756
};
2757+
let top_hotspots: Vec<Value> = health
2758+
.metrics
2759+
.evolution
2760+
.temporal_hotspots
2761+
.iter()
2762+
.take(TREND_TOP_HOTSPOTS_LIMIT)
2763+
.map(|h| {
2764+
serde_json::json!({
2765+
"path": h.path,
2766+
"commits": h.commits,
2767+
"max_complexity": h.max_complexity,
2768+
"risk_score": h.risk_score,
2769+
})
2770+
})
2771+
.collect();
2772+
let mut rule_breakdown: BTreeMap<String, usize> = BTreeMap::new();
2773+
for finding in &health.rules {
2774+
*rule_breakdown.entry(finding.code.clone()).or_default() += 1;
2775+
}
27472776
samples.push(serde_json::json!({
2777+
"schema": TREND_SCHEMA_VERSION,
27482778
"timestamp": unix_time(),
27492779
"snapshot_id": report.snapshot.snapshot_id,
27502780
"score": health.score,
@@ -2754,6 +2784,8 @@ fn append_trend_sample(report: &ScanReport, health: &HealthSummary) -> Result<Pa
27542784
"functions": report.functions.len(),
27552785
"root_causes": health.root_causes,
27562786
"overall_grade": health.grades.overall,
2787+
"top_hotspots": top_hotspots,
2788+
"rule_breakdown": rule_breakdown,
27572789
}));
27582790
fs::write(&path, serde_json::to_string_pretty(&samples)?)
27592791
.with_context(|| format!("failed to write {}", path.display()))?;
@@ -2871,6 +2903,15 @@ fn save_baseline(root: &Path, output: &Path, config_path: Option<&Path>) -> Resu
28712903
let report = scan_path_with_config(root, &config)?;
28722904
let health = compute_health_with_config(&report, &config);
28732905
let baseline = build_baseline(&report, &health);
2906+
2907+
// Record the trend sample first so this snapshot is part of the
2908+
// history that the splayed `trend_*` tables read from. Failures
2909+
// here are non-fatal: the trend log is best-effort, the baseline
2910+
// itself is what the user asked for.
2911+
if let Err(reason) = append_trend_sample(&report, &health) {
2912+
eprintln!("warning: failed to record trend sample: {reason}");
2913+
}
2914+
28742915
let memory = crate::memory::RayMemory::from_report_with_config(&report, &config)?;
28752916
let tables_dir = output.join("tables");
28762917

@@ -2889,13 +2930,6 @@ fn save_baseline(root: &Path, output: &Path, config_path: Option<&Path>) -> Resu
28892930
.save_splayed(&tables_dir)
28902931
.with_context(|| format!("failed to write baseline tables {}", tables_dir.display()))?;
28912932

2892-
// Record a trend sample so the score-drift series captures every
2893-
// baseline save automatically. Failures here are non-fatal: the
2894-
// baseline itself succeeded, the trend log is best-effort.
2895-
if let Err(reason) = append_trend_sample(&report, &health) {
2896-
eprintln!("warning: failed to record trend sample: {reason}");
2897-
}
2898-
28992933
Ok(())
29002934
}
29012935

@@ -3545,4 +3579,43 @@ mod tests {
35453579
assert!(summary.skipped.is_empty());
35463580
fs::remove_dir_all(&root).unwrap();
35473581
}
3582+
3583+
#[test]
3584+
fn append_trend_sample_writes_v2_schema() {
3585+
// Run the full pipeline against this crate so we get a real
3586+
// ScanReport + HealthSummary. Redirect the trend file into a
3587+
// temp dir by overriding the snapshot root post-scan.
3588+
let root = temp_root("trend-v2");
3589+
fs::create_dir_all(&root).unwrap();
3590+
let mut report = crate::scan_path(env!("CARGO_MANIFEST_DIR")).unwrap();
3591+
// Point the writer at the temp dir, but keep the rest of the
3592+
// report intact so health computation has real data to chew on.
3593+
report.snapshot.root = root.clone();
3594+
let health = crate::compute_health(&report);
3595+
3596+
let path = append_trend_sample(&report, &health).unwrap();
3597+
let content = fs::read_to_string(&path).unwrap();
3598+
let samples: Vec<Value> = serde_json::from_str(&content).unwrap();
3599+
assert_eq!(samples.len(), 1);
3600+
let sample = &samples[0];
3601+
assert_eq!(sample["schema"].as_u64(), Some(TREND_SCHEMA_VERSION as u64));
3602+
assert!(
3603+
sample.get("top_hotspots").is_some(),
3604+
"v2 sample must carry top_hotspots field"
3605+
);
3606+
assert!(
3607+
sample.get("rule_breakdown").is_some(),
3608+
"v2 sample must carry rule_breakdown field"
3609+
);
3610+
// Cap is enforced.
3611+
let hotspots = sample["top_hotspots"].as_array().unwrap();
3612+
assert!(
3613+
hotspots.len() <= TREND_TOP_HOTSPOTS_LIMIT,
3614+
"expected <= {} hotspots, got {}",
3615+
TREND_TOP_HOTSPOTS_LIMIT,
3616+
hotspots.len()
3617+
);
3618+
3619+
fs::remove_dir_all(&root).unwrap();
3620+
}
35483621
}

0 commit comments

Comments
 (0)