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
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"crates/autophagy-cli",
"crates/autophagy-core",
"crates/autophagy-events",
"crates/autophagy-patterns",
"crates/autophagy-store",
]

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ change because of what happened?”
Autophagy is in foundation development. Agent Event Protocol (AEP) v0.1, the
transactional local SQLite event store, generic JSONL CLI vertical slice, and
incremental Claude Code and Codex history adapters are implemented. No daemon
or background capture ships yet.
or background capture ships yet. Deterministic repeated-failure and explicit
user-correction detectors now emit versioned, evidence-linked packets.

## Principles

Expand All @@ -33,6 +34,7 @@ crates/autophagy-adapter-test-support/ Shared native-adapter conformance checks
crates/autophagy-cli/ User-facing import, sessions, and search commands
crates/autophagy-core/ Reusable streaming import application services
crates/autophagy-events/ AEP Rust types, parsing, and validation
crates/autophagy-patterns/ Model-free recurrence detectors and evidence packets
crates/autophagy-store/ SQLite migrations, idempotency, FTS, and deletion
docs/architecture/ Planned component and storage boundaries
docs/blueprint/ Complete normalized product and implementation brief
Expand Down Expand Up @@ -88,6 +90,10 @@ mise exec -- cargo run -p autophagy-cli -- --output json \
The [Codex adapter guide](docs/guides/codex.md) documents its intentionally
narrow compatibility matrix and the upstream transcript-stability boundary.

The [deterministic findings guide](docs/guides/deterministic-findings.md)
documents recurrence thresholds, signature normalization, counterexamples, and
the versioned Evidence Packet contract.

## Try the contract

Install [mise](https://mise.jdx.dev/), then run:
Expand Down
24 changes: 24 additions & 0 deletions crates/autophagy-patterns/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "autophagy-patterns"
description = "Deterministic, evidence-linked pattern detectors for Autophagy"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
autophagy-events = { path = "../autophagy-events" }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
time.workspace = true

[dev-dependencies]
autophagy-core = { path = "../autophagy-core" }
autophagy-store = { path = "../autophagy-store" }
jsonschema = { version = "0.47", default-features = false }

[lints]
workspace = true
62 changes: 62 additions & 0 deletions crates/autophagy-patterns/src/correction.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use std::collections::BTreeMap;

use autophagy_events::{Event, EventKind};

use crate::{
DetectorConfig, DetectorKind, EvidencePacket, EvidenceReference, EvidenceSpecVersion,
score::{qualifies, score},
signature::{correction_counterexample, correction_signature, finding_id},
};

pub(crate) fn detect(events: &[Event], config: DetectorConfig) -> Vec<EvidencePacket> {
let mut corrections: BTreeMap<String, Vec<&Event>> = BTreeMap::new();
let mut counterexamples: BTreeMap<String, Vec<&Event>> = BTreeMap::new();
for event in events {
if event.kind == EventKind::UserCorrectedAgent {
if let Some(signature) = correction_signature(event) {
corrections.entry(signature).or_default().push(event);
}
} else if event.kind == EventKind::DecisionRecorded {
if let Some(signature) = correction_counterexample(event) {
counterexamples.entry(signature).or_default().push(event);
}
}
}

corrections
.into_iter()
.filter_map(|(signature, evidence)| {
let opposite = counterexamples
.get(&signature)
.map_or(&[][..], Vec::as_slice);
let recurrence = score(&evidence, opposite)?;
let packet_signature = format!("correction/v1|{signature}");
qualifies(&recurrence, config).then(|| EvidencePacket {
spec_version: EvidenceSpecVersion::V0_1,
finding_id: finding_id(
DetectorKind::RepeatedUserCorrection.as_str(),
&packet_signature,
),
detector: DetectorKind::RepeatedUserCorrection,
signature: packet_signature,
title: format!("Repeated user correction: {signature}"),
score: recurrence,
evidence: references(&evidence),
counterexamples: references(opposite),
})
})
.collect()
}

fn references(events: &[&Event]) -> Vec<EvidenceReference> {
let mut references = events
.iter()
.map(|event| EvidenceReference::from_event(event))
.collect::<Vec<_>>();
references.sort_by(|left, right| {
left.timestamp
.cmp(&right.timestamp)
.then_with(|| left.event_id.cmp(&right.event_id))
});
references
}
97 changes: 97 additions & 0 deletions crates/autophagy-patterns/src/evidence.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use autophagy_events::Event;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

/// Evidence packet wire-format version.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum EvidenceSpecVersion {
/// Initial evidence packet contract.
#[serde(rename = "evidence/0.1")]
V0_1,
}

/// Deterministic detector that produced a finding.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectorKind {
/// The same normalized command failed recurrently.
RepeatedCommandFailure,
/// The same explicitly classified user correction recurred.
RepeatedUserCorrection,
}

impl DetectorKind {
/// Stable serialized detector name.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::RepeatedCommandFailure => "repeated_command_failure",
Self::RepeatedUserCorrection => "repeated_user_correction",
}
}
}

/// Exact AEP event cited as support or a counterexample.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct EvidenceReference {
/// Stable event evidence identifier.
pub event_id: String,
/// Source session containing the event.
pub session_id: String,
/// Canonical occurrence timestamp.
#[serde(with = "time::serde::rfc3339")]
pub timestamp: OffsetDateTime,
/// AEP event kind.
pub event_type: String,
/// Policy-processed project path, when available.
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
}

impl EvidenceReference {
pub(crate) fn from_event(event: &Event) -> Self {
Self {
event_id: event.event_id.as_str().to_owned(),
session_id: event.session_id.as_str().to_owned(),
timestamp: event.timestamp,
event_type: event.kind.as_str().to_owned(),
project: event.project.clone(),
}
}
}

/// Inspectable integer-only recurrence score.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RecurrenceScore {
/// Supporting events.
pub occurrences: u32,
/// Distinct sessions containing support.
pub distinct_sessions: u32,
/// Events demonstrating the opposite outcome.
pub counterexamples: u32,
/// Support share among support and counterexamples, in basis points.
pub support_ratio_bps: u16,
/// Overall deterministic score in basis points.
pub score_bps: u16,
}

/// Versioned, model-free finding with exact evidence lineage.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct EvidencePacket {
/// Evidence packet contract version.
pub spec_version: EvidenceSpecVersion,
/// Stable content-derived finding identity.
pub finding_id: String,
/// Detector implementation that emitted the packet.
pub detector: DetectorKind,
/// Versioned normalized recurrence signature.
pub signature: String,
/// Deterministic human-readable label.
pub title: String,
/// Inspectable recurrence statistics and score.
pub score: RecurrenceScore,
/// Supporting events in canonical order.
pub evidence: Vec<EvidenceReference>,
/// Opposite-outcome events in canonical order.
pub counterexamples: Vec<EvidenceReference>,
}
98 changes: 98 additions & 0 deletions crates/autophagy-patterns/src/failure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use std::collections::BTreeMap;

use autophagy_events::{Event, EventKind};

use crate::{
DetectorConfig, DetectorKind, EvidencePacket, EvidenceReference, EvidenceSpecVersion,
score::{qualifies, score},
signature::{FailureOperation, failure_operation, finding_id},
};

struct FailureGroup<'a> {
label: String,
success_key: String,
events: Vec<&'a Event>,
}

pub(crate) fn detect(events: &[Event], config: DetectorConfig) -> Vec<EvidencePacket> {
let mut failures: BTreeMap<String, FailureGroup<'_>> = BTreeMap::new();
let mut successes: BTreeMap<String, Vec<&Event>> = BTreeMap::new();

for event in events {
match event.kind {
EventKind::ToolFailed => {
if let Some(operation) = failure_operation(event, true) {
add_failure(&mut failures, operation, event);
}
}
EventKind::ToolCompleted => {
if let Some(operation) = failure_operation(event, false) {
successes
.entry(operation.success_key)
.or_default()
.push(event);
}
}
_ => {}
}
}

failures
.into_iter()
.filter_map(|(signature, group)| {
let counterexamples = successes
.get(&group.success_key)
.map_or(&[][..], Vec::as_slice);
let recurrence = score(&group.events, counterexamples)?;
qualifies(&recurrence, config).then(|| EvidencePacket {
spec_version: EvidenceSpecVersion::V0_1,
finding_id: finding_id(DetectorKind::RepeatedCommandFailure.as_str(), &signature),
detector: DetectorKind::RepeatedCommandFailure,
signature,
title: format!("Repeated command failure: {}", truncate(&group.label, 160)),
score: recurrence,
evidence: references(&group.events),
counterexamples: references(counterexamples),
})
})
.collect()
}

fn add_failure<'a>(
failures: &mut BTreeMap<String, FailureGroup<'a>>,
operation: FailureOperation,
event: &'a Event,
) {
failures
.entry(operation.signature)
.or_insert_with(|| FailureGroup {
label: operation.label,
success_key: operation.success_key,
events: Vec::new(),
})
.events
.push(event);
}

fn references(events: &[&Event]) -> Vec<EvidenceReference> {
let mut references = events
.iter()
.map(|event| EvidenceReference::from_event(event))
.collect::<Vec<_>>();
references.sort_by(|left, right| {
left.timestamp
.cmp(&right.timestamp)
.then_with(|| left.event_id.cmp(&right.event_id))
});
references
}

fn truncate(value: &str, limit: usize) -> String {
let mut chars = value.chars();
let prefix = chars.by_ref().take(limit).collect::<String>();
if chars.next().is_some() {
format!("{prefix}…")
} else {
prefix
}
}
46 changes: 46 additions & 0 deletions crates/autophagy-patterns/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Deterministic pattern discovery over validated AEP events.
//!
//! Detectors never call a model. Every finding carries exact supporting and
//! counterexample event identifiers and uses integer-only recurrence scoring.

mod correction;
mod evidence;
mod failure;
mod score;
mod signature;

pub use evidence::{
DetectorKind, EvidencePacket, EvidenceReference, EvidenceSpecVersion, RecurrenceScore,
};

use autophagy_events::Event;

/// Thresholds shared by deterministic recurrence detectors.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DetectorConfig {
/// Minimum supporting events required.
pub min_occurrences: u32,
/// Minimum distinct sessions containing support.
pub min_sessions: u32,
/// Minimum evidence share among support and counterexamples, in basis points.
pub min_support_ratio_bps: u16,
}

impl Default for DetectorConfig {
fn default() -> Self {
Self {
min_occurrences: 3,
min_sessions: 2,
min_support_ratio_bps: 5_000,
}
}
}

/// Run every Milestone 1 deterministic detector and return stable ordering.
#[must_use]
pub fn detect(events: &[Event], config: DetectorConfig) -> Vec<EvidencePacket> {
let mut findings = failure::detect(events, config);
findings.extend(correction::detect(events, config));
findings.sort_by(|left, right| left.finding_id.cmp(&right.finding_id));
findings
}
Loading