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
66 changes: 64 additions & 2 deletions Cargo.lock

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

103 changes: 87 additions & 16 deletions aw-transform/src/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use aw_models::Event;
use fancy_regex::Regex;
use lru::LruCache;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex, OnceLock};

Expand Down Expand Up @@ -117,27 +118,40 @@ impl From<Regex> for Rule {
/// An event can only have one category, although the category may have a hierarchy,
/// for instance: "Work -> ActivityWatch -> aw-server-rust"
/// If multiple categories match, the deepest one will be chosen.
///
/// Performance: builds an in-memory cache keyed on the event's data JSON so that
/// events with identical data (same app/title — very common in practice) are only
/// matched against the rule set once. On a month's data with 50k+ events but only
/// a few hundred distinct app/title pairs this reduces regex work by >99%.
Comment on lines +121 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Benchmark the cache optimization

The new implementation adds JSON serialization, hashing, and category cloning for every event, while the test covers only output correctness. Add representative profiling or benchmark results to validate the stated greater-than-99-percent reduction and guard the month-view improvement against regression.

Rule Used: Before implementing performance optimizations, mea... (source)

Learned From
gptme/gptme#707

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

pub fn categorize(mut events: Vec<Event>, rules: &[(Vec<String>, Rule)]) -> Vec<Event> {
let mut classified_events = Vec::new();
for event in events.drain(..) {
classified_events.push(categorize_one(event, rules));
// Cache: serialized event data → assigned category
let mut category_cache: HashMap<String, Vec<String>> = HashMap::new();
let mut classified_events = Vec::with_capacity(events.len());
for mut event in events.drain(..) {
// Key on the full event data. serde_json::Map preserves insertion order, so
// events with the same fields in the same order produce the same key — which
// is the normal case for heartbeat-based watchers.
Comment on lines +131 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Correct the map-order explanation

The current serde_json::Map configuration uses a key-sorted map rather than preserving insertion order. The comment therefore gives maintainers the wrong reason equivalent maps produce stable cache keys and becomes especially misleading when evaluating feature changes.

Suggested change
// Key on the full event data. serde_json::Map preserves insertion order, so
// events with the same fields in the same order produce the same key — which
// is the normal case for heartbeat-based watchers.
// Key on the full event data. With serde_json's default map implementation,
// keys are sorted, so equivalent event data produces the same serialized key.

let cache_key = serde_json::to_string(&event.data).unwrap_or_default();
let category = category_cache
.entry(cache_key)
.or_insert_with(|| {
let mut cat = vec!["Uncategorized".into()];
for (c, rule) in rules {
if rule.matches(&event) {
cat = _pick_highest_ranking_category(cat, c);
}
}
cat
})
.clone();
event
.data
.insert("$category".into(), serde_json::json!(category));
classified_events.push(event);
}
classified_events
}

fn categorize_one(mut event: Event, rules: &[(Vec<String>, Rule)]) -> Event {
let mut category: Vec<String> = vec!["Uncategorized".into()];
for (cat, rule) in rules {
if rule.matches(&event) {
category = _pick_highest_ranking_category(category, cat);
}
}
event
.data
.insert("$category".into(), serde_json::json!(category));
event
}

/// Tags a list of events
///
/// An event can have many tags (as opposed to only one category) which will be put into the `$tags` key of
Expand Down Expand Up @@ -290,6 +304,63 @@ fn test_categorize_uncategorized() {
);
}

#[test]
fn test_categorize_cache_correctness() {
// Verifies that the deduplication cache produces the same result as
// per-event categorization when many events share the same data.
let mut base = Event::default();
base.data.insert("app".into(), serde_json::json!("firefox"));
base.data
.insert("title".into(), serde_json::json!("GitHub"));

let mut other = Event::default();
other
.data
.insert("app".into(), serde_json::json!("terminal"));
other.data.insert("title".into(), serde_json::json!("bash"));

// 50 events with same data, then 1 different event, then 50 more same
let mut events: Vec<Event> = std::iter::repeat(base.clone())
.take(50)
.chain(std::iter::once(other.clone()))
.chain(std::iter::repeat(base.clone()).take(50))
.collect();

let rules: Vec<(Vec<String>, Rule)> = vec![
(
vec!["Browser".into()],
Rule::Regex(RegexRule::new("firefox", true, Some(vec!["app".into()])).unwrap()),
),
(
vec!["Terminal".into()],
Rule::Regex(RegexRule::new("terminal", true, Some(vec!["app".into()])).unwrap()),
),
];

events = categorize(events, &rules);

assert_eq!(events.len(), 101);
// All firefox events → Browser
for e in events.iter().take(50) {
assert_eq!(
e.data.get("$category").unwrap(),
&serde_json::json!(vec!["Browser"])
);
}
// The single terminal event → Terminal
assert_eq!(
events[50].data.get("$category").unwrap(),
&serde_json::json!(vec!["Terminal"])
);
// Remaining firefox events → Browser (cache hit path)
for e in events.iter().skip(51) {
assert_eq!(
e.data.get("$category").unwrap(),
&serde_json::json!(vec!["Browser"])
);
}
}

#[test]
fn test_tag() {
let mut e = Event::default();
Expand Down
Loading