Skip to content

Classifier Architecture

Full spec: 00-product-spec.md.

This document describes the entity classifier: the single LLM-based semantic judge that decides whether a candidate observed from the user’s activity is a real person, project, task, or app entity, or noise. It covers the input and output structs, the prompt rules, the call sites that route through it, the fallback chain, DryRun backend behavior, and how it relates to sighting dedup and importer idempotency.

  • Single judge: crates/yzos-core/src/entity/classifier.rs::classify() is the only semantic gate for entity promotion. All five promotion and re-evaluation paths (promote_candidate, merge_or_create_project, merge_or_create_task, apply_entities, entity_purge_v06) go through this function.
  • Semantic judgment is not hardcoded: the classifier is designed to make every “is this a real entity” decision through the LLM rather than through keyword or domain lists baked into the code. This keeps the classifier adaptable to new tools, apps, and naming conventions without code changes.
  • Layered defense: noise_filter only checks structural properties (empty strings, single characters, pure numbers, path segments); it does not judge semantics. When the classifier is unreachable, promotion falls back to noise_filter (the LLM-failure fallback).
  • DryRun passthrough: when scheduler.active_model_id() returns None, the classifier returns is_real_entity=true with kind=suggested_kind, so CI, unit, and end-to-end tests do not depend on a live LLM.
  • Terminal state is not reversible: an entity the classifier marks as noise transitions to archived, and entity_overrides records status=archived. updater::update_existing short-circuits on a terminal state; later sightings are only counted for dedup, never used to push status or summary or OKF content back to active.
pub struct ClassifyInput {
pub raw_text: String, // candidate text (display_name / hypothesized_name)
pub source_kind: String, // origin: cron / llm_triage / clipboard / ai_tool
pub source_id: String, // origin id (candidate_id / session_id)
pub suggested_kind: Option<String>, // upstream-proposed kind (project / task / person)
pub app_bundle_id: Option<String>, // matched bundle (com.figma.Desktop / com.tdesktop.Telegram)
pub app_metadata: Option<AppMetadata>, // Info.plist LSApplicationCategoryType + CFBundleName
pub surrounding_text: Option<String>, // recent sighting / source_text snippet, given to the LLM as context
}

app_metadata is central to the design: the macOS Info.plist LSApplicationCategoryType (for example public.app-category.social-networking) lets the classifier immediately recognize that Telegram or Discord is a social app rather than a project. observe/active_window reads this value on every foreground app switch and upserts it into the app_metadata table; the classifier’s prompt builder looks it up and includes it in the prompt.

pub struct ClassifyVerdict {
pub is_real_entity: bool,
pub kind: Option<String>, // person / project / task / app / noise
pub display_name: Option<String>, // LLM-normalized name (e.g. "YZOS Core")
pub confidence: f32,
pub reason: String,
}

Five kind values:

kind Meaning Handling
person a real person promoted to entity
project a real project (code repository, documentation project) promoted to entity
task a concrete, trackable work item promoted to entity
app a tool or application itself (Telegram, Cursor) forced reject
noise a command fragment, UI text, or template string forced reject

When kind=app or kind=noise, is_real_entity is always false. Even if the LLM misjudges elsewhere, the prompt explicitly instructs that neither value may enter the active entity table.

The prompt is assembled in classifier::build_prompt(input). It carries rules rather than a hardcoded keyword list:

  • The LLM is shown raw_text, suggested_kind, app_metadata.category, and surrounding_text, and asked whether the candidate is a real person, project, or task.
  • It is told explicitly: tool names, single-token commands, UUIDs, path segments, and template strings are noise; apps in categories such as social-networking, utilities, or developer-tools are never a project in their own right.
  • Output is constrained with GBNF plus a JSON schema; kind is restricted to {person, project, task, app, noise}.
  • The LLM decides ambiguous cases from context. For example, “Cursor” mentioned in surrounding text such as “used Cursor to edit the yzos code” is judged app (a suggested project=Cursor is rejected); the same name in “syncing with the Cursor team” is also judged app, not person, even though it looks like a proper name by capitalization alone.
Entry point Trigger Failure behavior
updater::promote_candidate cron entity_promote fallback to noise_filter
engine::merge_or_create_project apply_session_triage skipped
engine::merge_or_create_task apply_session_triage skipped
supply::pipeline::apply_entities inside process_session_end inline promotion skipped
maintain::jobs::run_entity_purge_v06 cron, every 6h state left unchanged

Three of these are promotion paths that gate new candidates before they become entities; the fourth is a re-evaluation path that periodically revisits existing active entities. Together they form a closed loop: a new candidate must pass LLM judgment to become active, and existing entities are periodically re-checked, so noise cannot persist indefinitely.

LLM reachable -> classifier::classify (semantic gate)
|
v failure (LlmFailed / timeout / DryRun=None)
noise_filter::is_noise (structural gate: empty string / single char / pure number / path segment)
|
v match
mark_candidate_rejected (terminal state)
  • DryRun passes candidates through transparently, so unit and end-to-end tests run without an LLM.
  • When the live LLM fails, the fallback does not reject every candidate; it only filters out obviously structural garbage, so observe is not blocked.

Under InferenceProfile { llm_backend: LlmBackend::DryRun, .. }:

  • scheduler.active_model_id() returns None.
  • The first check in classifier::classify detects None and returns ClassifyVerdict { is_real_entity: true, kind: input.suggested_kind, ... } immediately.
  • None of the three promotion entry points block; candidates are written to entities using their original suggested_kind.
  • CI, unit tests, and cargo test --workspace run end to end without an LLM sidecar.

noise_filter retains only structural responsibilities:

  • is_meaningful_summary: checks that a summary is not a UUID, a bare path, or a raw machine dump.
  • is_plausible_project_path: checks that a path contains segments like Code, Volumes, or another real working-directory marker.
  • normalize_project_path: strips trailing slashes, file names, and similar noise from a path.

Semantic judgment, such as whether “Cursor” counts as a project, is not handled in noise_filter at all; it is entirely delegated to the classifier. There are no keyword or domain arrays in this module to grep for.

Migration 018 adds a UNIQUE (entity_id, source_kind, source_id, fingerprint) constraint on entity_sightings. fingerprint = blake3(source_id || summary). This constraint means the code does not rely on a “SELECT then INSERT” check-then-act pattern; a duplicate write for the same record is rejected directly by PostgreSQL. All nine add_sighting call sites pass a fingerprint.

10. Importer Idempotency Across Restarts (Schema + Application Layer)

Section titled “10. Importer Idempotency Across Restarts (Schema + Application Layer)”

Migration 020 introduces importer_seen (importer_name, session_id, record_id, last_seen). The aider importer locks by session segment; generic importers lock by record_id. After a process restart, an importer resumes from WHERE record_id NOT IN (SELECT record_id FROM importer_seen) instead of rescanning full session history, and does not produce duplicate sightings.

  • migrations/018_entity_sighting_unique.sql
  • migrations/019_app_metadata.sql
  • migrations/020_importer_seen.sql
  • crates/yzos-core/src/entity/classifier.rs
  • crates/yzos-core/src/entity/updater.rs#promote_candidate
  • crates/yzos-core/src/entity/engine.rs#merge_or_create_project
  • crates/yzos-core/src/maintain/jobs.rs#run_entity_purge_v06
  • crates/yzos-core/src/observe/app_meta.rs
  • crates/yzos-core/src/supply/pipeline.rs#apply_entities