From 2405da11858a6b24d576506f7482ba26d3e7a196 Mon Sep 17 00:00:00 2001 From: Steve <1407088+stevepridemore@users.noreply.github.com> Date: Sun, 17 May 2026 15:51:30 -0400 Subject: [PATCH] feat: Rule subtype for permanent preferences (no decay/prune) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `subtype: 'rule'` semantic on Preference (and any) nodes for permanent constraints β€” "never X" / "always Y" with no expected sunset. Rule-subtype nodes are exempt from: - Time-based decay (both node confidence and edges touching them) - Low-confidence pruning (defense-in-depth; rules sit at 1.0 anyway) - Orphan pruning (a stranded rule should be reconnected, not deleted) - Edge-weight pruning when either endpoint is a rule The rule's anchor edge (typically Person -[PREFERS]-> Rule) is held at weight 1.0 and exempt from edge decay so the rule never drifts loose. Schema docs (project + universal) updated to describe the subtype. Extractor prompts (dream + graph-capture) updated to recognize hard "never/always" statements and emit them as Rules rather than Preferences. Backward-compatible: all existing nodes have subtype=null and are unaffected by the new filters. Co-Authored-By: Claude Opus 4.7 (1M context) --- GRAPH_SCHEMA.md | 2 +- prompts/dream-nightly.md | 1 + prompts/graph-capture.md | 1 + src/shared/neo4j-client.ts | 29 ++++++++++++++++++++++++----- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/GRAPH_SCHEMA.md b/GRAPH_SCHEMA.md index 2fb1775..798fd4f 100644 --- a/GRAPH_SCHEMA.md +++ b/GRAPH_SCHEMA.md @@ -41,7 +41,7 @@ init Cypher, example queries) see | `Technology` βœ… πŸ€” | A specific tool, language, library, or platform | Overlaps with `Concept`. Currently a categorized Concept; the live graph uses both. Convention going forward: prefer `Technology` for concrete tech (React, Neo4j); `Concept` for abstractions (LLM-wiki-pattern, MVC). | | `Decision` βœ… | A choice made or position taken | Often emitted by the dream extractor from "we decided…" / "I chose…" statements. | | `Reasoning` βœ… | The why behind a Decision | Pairs with Decision via `LED_TO`; lighter than Decision itself. | -| `Preference` βœ… | A stated user preference or rule | Person `PREFERS` Preference. Has `domain`, `key`, `value` properties. | +| `Preference` βœ… | A stated user preference | Person `PREFERS` Preference. Has `domain`, `key`, `value` properties. Use `subtype: 'rule'` for permanent/hard-rule preferences ("never X", "always Y") β€” these are pinned at `confidence: 1.0` and exempt from decay and pruning. They change only on explicit user statement. | | `Event` βœ… | A point-in-time happening | Meeting, milestone, release, incident | | `Fact` βœ… | A standalone piece of knowledge | Description-heavy. Best paired with `ABOUT` to whatever it's a fact *about*. | | `Artifact` βœ… | A created/authored output (doc, file, transcript, gist) | Subtype of Object β€” distinct because authorship matters. Pair with `AUTHORED` / `PRODUCED`. | diff --git a/prompts/dream-nightly.md b/prompts/dream-nightly.md index 0fb9627..6be30a7 100644 --- a/prompts/dream-nightly.md +++ b/prompts/dream-nightly.md @@ -103,6 +103,7 @@ Focus on **knowledge**, not conversation mechanics. Skip "let me read that file" - **Projects** discussed, worked on, or referenced - **Technologies/Concepts** used, evaluated, or discussed - **Preferences** stated or confirmed ("I prefer X", "always use Y") + - **Rule subtype**: when the user states a hard, permanent constraint ("never X", "always Y" β€” no expected sunset), capture as a Preference with `subtype: 'rule'`, `confidence: 1.0`, and `PREFERS` edge weight `1.0`. Rules are exempt from decay/prune and only change on explicit user statement. Reserve for permanent constraints, not soft preferences. - **Decisions** made with reasoning ("we decided to X because Y") - **Facts** about infrastructure, processes, or configuration - **Events** β€” meetings, deployments, incidents, milestones diff --git a/prompts/graph-capture.md b/prompts/graph-capture.md index 7aad10e..7ef0c82 100644 --- a/prompts/graph-capture.md +++ b/prompts/graph-capture.md @@ -23,6 +23,7 @@ Walk the conversation (or the slice indicated by `--since-message` / `--topic`) - **Projects** worked on, evaluated, or referenced with meaningful context - **Technologies / Concepts** the user used, evaluated, decided about, or expressed a preference toward - **Preferences** explicitly stated ("I prefer X", "always use Y") or strongly implied through repeated choice + - **Rule subtype**: when the user states a hard, permanent constraint with no expected sunset ("never X", "always Y", "X must never appear in Z"), capture as a Preference with `subtype: 'rule'`, `confidence: 1.0`, and a `PREFERS` edge weight of `1.0`. Rules are exempt from decay and pruning and only change on explicit user statement. Reserve this for genuinely permanent constraints, not soft preferences. - **Decisions** made with reasoning ("we decided X because Y") β€” both explicit and clear inferred decisions - **Facts** about infrastructure, processes, configuration, or the user's environment - **Events** β€” meetings, deployments, incidents, milestones with dates or outcomes diff --git a/src/shared/neo4j-client.ts b/src/shared/neo4j-client.ts index c89c7d2..2750658 100644 --- a/src/shared/neo4j-client.ts +++ b/src/shared/neo4j-client.ts @@ -881,6 +881,7 @@ export class Neo4jClient { ` MATCH (n:\`${type}\` {tenant_id: $tenantId}) WHERE n.last_seen < datetime() - duration('P1D') + AND (n.subtype IS NULL OR n.subtype <> 'rule') RETURN count(n) AS count `, { tenantId }, @@ -893,9 +894,12 @@ export class Neo4jClient { ` MATCH (n:\`${type}\` {tenant_id: $tenantId}) WHERE n.last_seen < datetime() - duration('P1D') + AND (n.subtype IS NULL OR n.subtype <> 'rule') // duration.inDays() forces an all-days representation; using .days // on the normalized duration.between() would drop the months // component (30 days back β†’ "1 month + 0 days" β†’ 0-day decay). + // Nodes with subtype='rule' (permanent preferences) are exempt + // from decay entirely β€” they only change on explicit user statement. WITH n, n.confidence * ($rate ^ duration.inDays(n.last_seen, datetime()).days) AS new_conf SET n.confidence = CASE WHEN new_conf < 0.01 THEN 0.01 ELSE new_conf END RETURN count(n) AS decayed @@ -905,12 +909,16 @@ export class Neo4jClient { totalNodesDecayed += Number(rows[0]?.["decayed"] ?? 0); } - // Decay edges (both endpoints must be in tenant) + // Decay edges (both endpoints must be in tenant). Edges touching a + // subtype='rule' node on either side are exempt β€” otherwise a rule's + // anchor PREFERS edge would slowly decay and orphan the rule. const edgeRows = await this.run( ` MATCH (a:Entity {tenant_id: $tenantId})-[r]->(b:Entity {tenant_id: $tenantId}) WHERE r.last_confirmed < datetime() - duration('P1D') AND r.weight IS NOT NULL + AND (a.subtype IS NULL OR a.subtype <> 'rule') + AND (b.subtype IS NULL OR b.subtype <> 'rule') WITH r, r.weight * ($rate ^ duration.inDays(r.last_confirmed, datetime()).days) AS new_weight SET r.weight = CASE WHEN new_weight < 0.01 THEN 0.01 ELSE new_weight END RETURN count(r) AS decayed @@ -920,11 +928,14 @@ export class Neo4jClient { totalEdgesDecayed = Number(edgeRows[0]?.["decayed"] ?? 0); } - // Count nodes flagged for pruning (tenant-scoped) + // Count nodes flagged for pruning (tenant-scoped). Rule-subtype nodes + // are pinned at confidence 1.0 so they'd never cross the threshold, + // but we filter them explicitly for clarity and defense-in-depth. const pruneRows = await this.run( ` MATCH (n:Entity {tenant_id: $tenantId}) WHERE n.confidence < $threshold + AND (n.subtype IS NULL OR n.subtype <> 'rule') OPTIONAL MATCH (n)-[r]-(other:Entity {tenant_id: $tenantId}) WITH n, max(r.weight) AS max_edge_weight WHERE max_edge_weight IS NULL OR max_edge_weight < $edgeThreshold @@ -1230,11 +1241,13 @@ export class Neo4jClient { const includeOrphans = options.include_orphans ?? true; const maxAgeDays = options.max_age_days ?? config.decay.prune_orphan_days; - // Find pruneable nodes (tenant-scoped) + // Find pruneable nodes (tenant-scoped). Rule-subtype nodes are + // permanent and exempt from pruning regardless of confidence. const nodeRows = await this.run( ` MATCH (n:Entity {tenant_id: $tenantId}) WHERE n.confidence < $nodeThreshold + AND (n.subtype IS NULL OR n.subtype <> 'rule') OPTIONAL MATCH (n)-[r]-(other:Entity {tenant_id: $tenantId}) WITH n, labels(n) AS labels, max(r.weight) AS maxEdge WHERE maxEdge IS NULL OR maxEdge < $edgeThreshold @@ -1245,7 +1258,9 @@ export class Neo4jClient { { tenantId, nodeThreshold, edgeThreshold }, ); - // Find orphans if requested + // Find orphans if requested. Rule-subtype nodes are exempt even if + // they become disconnected β€” a stranded rule should be reconnected, + // not deleted. let orphanRows: Row[] = []; if (includeOrphans) { orphanRows = await this.run( @@ -1254,6 +1269,7 @@ export class Neo4jClient { WHERE NOT (n)-[]-() AND n.last_seen < datetime() - duration({days: $maxAgeDays}) AND n.confidence >= $nodeThreshold + AND (n.subtype IS NULL OR n.subtype <> 'rule') RETURN n.id AS id, n.name AS name, [l IN labels(n) WHERE l <> 'Entity'][0] AS type, n.confidence AS confidence @@ -1262,11 +1278,14 @@ export class Neo4jClient { ); } - // Find pruneable edges (both endpoints in tenant) + // Find pruneable edges (both endpoints in tenant). Edges touching a + // rule-subtype node on either side are exempt. const edgeRows = await this.run( ` MATCH (a:Entity {tenant_id: $tenantId})-[r]->(b:Entity {tenant_id: $tenantId}) WHERE r.weight < $edgeThreshold + AND (a.subtype IS NULL OR a.subtype <> 'rule') + AND (b.subtype IS NULL OR b.subtype <> 'rule') RETURN a.id AS fromId, b.id AS toId, type(r) AS relType, r.weight AS weight `, { tenantId, edgeThreshold },