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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions crates/autophagy-cli/src/detection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! Persisted detection-findings cache and detection progress reporting.
//!
//! Every findings-consuming command (`patterns`, `digest`, `mutations propose`,
//! `mutations synthesize`, `status --with-findings`) runs the same deterministic
//! detection pass over every stored event. On a large corpus that pass costs
//! tens of seconds, so [`detect_cached`] memoizes the report in the store keyed
//! by a content fingerprint of its exact inputs. An unchanged corpus at
//! unchanged thresholds returns instantly; any change to the events, thresholds,
//! project filter, or detector spec version misses and recomputes. When a fresh
//! pass does run, a concise before/after progress line is written to stderr so
//! the command never appears hung — and stdout stays a clean report.

use std::{io::Write, time::Instant};

use autophagy_patterns::{
DETECTION_SPEC_VERSION, DetectionReport, DetectorConfig, detect_with_report,
};
use autophagy_store::{DetectionFingerprint, EventStore};
use sha2::{Digest, Sha256};

use crate::CliError;

/// Return the detection report for `project` at `config`, served from the store
/// cache when the corpus and thresholds are unchanged, otherwise computed fresh
/// and cached. Setting `recompute` forces a fresh pass and refreshes the entry.
///
/// The returned report is byte-for-byte identical whether it came from the cache
/// or a fresh pass, so every caller — findings, diagnostics, and the exact
/// evidence identifiers each finding carries — sees the same deterministic
/// result.
///
/// # Errors
///
/// Returns [`CliError`] when the store cannot be queried or a stored event no
/// longer satisfies the AEP contract.
pub(crate) fn detect_cached(
store: &EventStore,
project: Option<&str>,
config: DetectorConfig,
recompute: bool,
) -> Result<DetectionReport, CliError> {
let fingerprint = store.detection_fingerprint(project)?;
let key = cache_key(project, config, &fingerprint);
if !recompute {
if let Some(json) = store.cached_findings(&key)? {
if let Ok(report) = serde_json::from_str::<DetectionReport>(&json) {
return Ok(report);
}
// A payload from an older cache shape that no longer deserializes is
// ignored and recomputed below rather than surfaced as an error.
}
}
let report = run_with_progress(store, project, config, &fingerprint)?;
let generation = store.detection_generation()?;
let json = serde_json::to_string(&report)?;
store.store_findings(&key, &generation, &json)?;
Ok(report)
}

/// Run a fresh detection pass, bracketing it with progress lines on stderr.
fn run_with_progress(
store: &EventStore,
project: Option<&str>,
config: DetectorConfig,
fingerprint: &DetectionFingerprint,
) -> Result<DetectionReport, CliError> {
// Progress goes to stderr; stdout stays a clean report so JSON output mode is
// never polluted.
let mut stderr = std::io::stderr();
let _ = writeln!(
stderr,
"digesting {} events across {} sessions…",
group(fingerprint.event_count),
group(fingerprint.session_count),
);
let started = Instant::now();
let events = store.list_events_for_detection(project)?;
let report = detect_with_report(&events, config);
let _ = writeln!(
stderr,
"digested in {:.1}s — {} finding(s)",
started.elapsed().as_secs_f64(),
report.findings.len(),
);
Ok(report)
}

/// Derive the 32-byte cache validity key from every input a detection pass
/// depends on: the detector spec version, the effective thresholds, the project
/// filter, and the cheap content fingerprint (event count, max row id, and the
/// monotonic import watermark). A NUL separator keeps field boundaries
/// unambiguous so distinct inputs cannot collide by concatenation.
fn cache_key(
project: Option<&str>,
config: DetectorConfig,
fingerprint: &DetectionFingerprint,
) -> [u8; 32] {
let min_occurrences = config.min_occurrences.to_string();
let min_sessions = config.min_sessions.to_string();
let min_support_ratio_bps = config.min_support_ratio_bps.to_string();
let event_count = fingerprint.event_count.to_string();
let max_row_id = fingerprint.max_row_id.to_string();
let parts: [&str; 8] = [
DETECTION_SPEC_VERSION,
&min_occurrences,
&min_sessions,
&min_support_ratio_bps,
project.unwrap_or(""),
&event_count,
&max_row_id,
&fingerprint.import_watermark,
];
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part.as_bytes());
hasher.update([0_u8]);
}
hasher.finalize().into()
}

/// Group an integer's digits with thousands separators (`69,400`).
fn group(value: i64) -> String {
let digits = value.unsigned_abs().to_string();
let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index) % 3 == 0 {
grouped.push(',');
}
grouped.push(digit);
}
if value < 0 {
format!("-{grouped}")
} else {
grouped
}
}
52 changes: 34 additions & 18 deletions crates/autophagy-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

mod config;
mod daemon;
mod detection;
mod setup;
mod status;
mod watch;
Expand Down Expand Up @@ -38,8 +39,7 @@ use autophagy_install::{
};
use autophagy_mutations::{GenerationOutcome, equivalence_key, generate_candidates};
use autophagy_patterns::{
DetectionDiagnostics, DetectionReport, DetectorConfig, EvidencePacket, Observation, detect,
detect_with_report,
DetectionDiagnostics, DetectionReport, DetectorConfig, EvidencePacket, Observation,
};
use autophagy_replay::{
CounterfactualOutcome, ExpectedAction, ReplayDraftError, ReplayEvaluationError, ReplayReport,
Expand Down Expand Up @@ -216,6 +216,10 @@ enum Commands {

#[command(flatten)]
thresholds: ThresholdArgs,

/// Ignore any cached findings and run a fresh detection pass.
#[arg(long)]
recompute: bool,
},

/// List deterministic Evidence Packet v0.1 findings.
Expand All @@ -226,6 +230,10 @@ enum Commands {

#[command(flatten)]
thresholds: ThresholdArgs,

/// Ignore any cached findings and run a fresh detection pass.
#[arg(long)]
recompute: bool,
},

/// Manage review-only, zero-permission mutation candidates.
Expand Down Expand Up @@ -1275,27 +1283,31 @@ fn execute(
Commands::Digest {
project,
thresholds,
recompute,
} => {
let database = resolve_database_path(cli.database)?;
let store = open_store(&database)?;
let events = store.list_events_for_detection(project.as_deref())?;
let report = detect_with_report(
&events,
let report = detection::detect_cached(
&store,
project.as_deref(),
config::resolve_thresholds(leaf, thresholds, config),
);
recompute,
)?;
Ok(CommandReport::Digest(digest_report(report)?))
}
Commands::Patterns {
project,
thresholds,
recompute,
} => {
let database = resolve_database_path(cli.database)?;
let store = open_store(&database)?;
let events = store.list_events_for_detection(project.as_deref())?;
let report = detect_with_report(
&events,
let report = detection::detect_cached(
&store,
project.as_deref(),
config::resolve_thresholds(leaf, thresholds, config),
);
recompute,
)?;
let DetectionDiagnostics {
events_scanned,
sessions_scanned,
Expand Down Expand Up @@ -1492,11 +1504,13 @@ fn execute_mutation_action(
thresholds,
dry_run,
} => {
let events = store.list_events_for_detection(project.as_deref())?;
let findings = detect(
&events,
let findings = detection::detect_cached(
&store,
project.as_deref(),
config::resolve_thresholds(matches, thresholds, config),
);
false,
)?
.findings;
let generated = generate_candidates(&findings);
let mut registrations = Vec::new();
if !dry_run {
Expand Down Expand Up @@ -1613,11 +1627,13 @@ fn execute_mutation_action(
}
}
}
let events = store.list_events_for_detection(project.as_deref())?;
let findings = detect(
&events,
let findings = detection::detect_cached(
&store,
project.as_deref(),
config::resolve_thresholds(matches, thresholds, config),
);
false,
)?
.findings;
let synthesized =
synthesize_candidates(&findings, &model_manifest, synthesis_provider.as_ref());
let (total_prompt_tokens, total_completion_tokens) = aggregate_usage(&synthesized);
Expand Down
10 changes: 7 additions & 3 deletions crates/autophagy-cli/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use autophagy_adapter_pi::{
PiImportOptions, default_sessions_root as pi_sessions_root, discover as pi_discover, import_pi,
};
use autophagy_core::{ReindexOptions, ReindexSummary, reindex};
use autophagy_patterns::{DetectorConfig, detect_with_report};
use autophagy_patterns::DetectorConfig;
use autophagy_store::EventStore;
use clap::ValueEnum;
use serde::Serialize;
Expand Down Expand Up @@ -351,8 +351,12 @@ pub fn run(
// still shows the scan stats and near-threshold observations rather than a
// silent nothing.
ui.say("");
let events = store.list_events_for_detection(None)?;
let digest = digest_report(detect_with_report(&events, DetectorConfig::default()))?;
let digest = digest_report(crate::detection::detect_cached(
&store,
None,
DetectorConfig::default(),
false,
)?)?;
let digest_events_scanned = digest.events_scanned;
let digest_findings = digest.findings.len();
let digest_observations = digest.observations.len();
Expand Down
18 changes: 11 additions & 7 deletions crates/autophagy-cli/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

use std::{collections::BTreeMap, io::Write, path::PathBuf};

use autophagy_patterns::detect;
use serde::Serialize;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};

Expand Down Expand Up @@ -142,14 +141,19 @@ pub fn run(
})
.collect();

// Thresholds are cheap (config or defaults). Findings are opt-in: computing
// them means loading and deserializing every event and running a full
// detection pass — digest-cost on a large store — so `status` stays fast by
// default and only pays that cost when `--with-findings` is passed.
// Thresholds are cheap (config or defaults). Findings are opt-in: the first
// pass at a given corpus and thresholds loads and deserializes every event
// and runs a full detection pass — digest-cost on a large store — so
// `status` stays fast by default and only pays that cost when
// `--with-findings` is passed. The result is cached in the store, so
// repeats at an unchanged corpus are instant.
let detector = config.detector_config();
let findings = if with_findings {
let events = store.list_events_for_detection(None)?;
Some(detect(&events, detector).len())
Some(
crate::detection::detect_cached(&store, None, detector, false)?
.findings
.len(),
)
} else {
None
};
Expand Down
Loading