Promotion Profiles & Policies (Cypher API)¶
Promotion is the optional second half of NornicDB's scoring pipeline. Decay handles time-based fade and runs entirely on its own (see nornicdb-knowledge-policies and nornicdb-decay-tuning). Promotion does two distinct things:
- Tracks access —
ON ACCESSmutations write to per-entity access metadata (counters, last-access timestamps, smoothed signals). This is the only mechanism that updates access metadata. - Changes scores conditionally —
WHEN <predicate> APPLY PROFILE '<name>'causes a promotion profile'smultiplier/scoreFloor/scoreCapto be applied to the decayed score when the predicate is true.
These two responsibilities are independent:
- A policy with
ON ACCESSbut noWHENclauses only tracks. Scores are not promoted, butLAST_ACCESSEDdecay bindings on the same target can rely on the tracking. - A policy with
WHENbut noON ACCESSonly promotes. It evaluates predicates against existing properties / metadata at score time. - A policy with both does both.
Two objects¶
- Promotion profile —
CREATE PROMOTION PROFILE <name> OPTIONS { ... }: a named, untargeted parameter set. Inert until referenced by aWHEN ... APPLY PROFILE '<name>'clause. - Promotion policy —
CREATE PROMOTION POLICY <name> FOR (...) APPLY { ON ACCESS { ... } WHEN ... APPLY PROFILE '...' }: targets entities withFORand contains the access mutations and/orWHENclauses. TheFORclause uses the same target shapes as decay bindings (label, multi-label, edge type, wildcard).
How promotion enters the score¶
baseScore = baseDecay(t) // from the decay binding
promoted = baseScore * multiplier // multiplier from the matched WHEN's profile
clampedPromo = min(scoreCap, max(scoreFloor, promoted)) // promotion profile's floor/cap
finalScore = max(decayBindingFloor, clampedPromo) // decay binding's floor (independent)
suppressed = finalScore < visibilityThreshold // decay binding's threshold
Order: 1. Decay computes baseScore from the binding's parameters and the entity's anchor timestamp. 2. If a promotion policy targets this entity and a WHEN predicate matches, the predicate's profile contributes multiplier, scoreFloor, scoreCap. If no WHEN matches (or no policy targets the entity), multiplier = 1.0 and the promotion floor/cap don't apply. 3. The decay binding's scoreFloor clamps the result. 4. The decay binding's visibilityThreshold is the suppression cutoff.
This is why multiplier > 1 paired with scoreCap < 1 is meaningful: the multiplier lifts the hot path, the cap stops it from dominating ranking.
ON ACCESS mutations¶
CREATE PROMOTION POLICY episode_reinforcement
FOR (n:MemoryEpisode)
APPLY {
ON ACCESS {
SET n.accessCount = coalesce(n.accessCount, 0) + 1
SET n.lastAccessedAt = timestamp()
SET n.totalDuration = coalesce(n.totalDuration, 0) + $duration
}
WHEN n.accessCount >= 3
APPLY PROFILE 'reinforced_episode'
}
What ON ACCESS actually does, precisely:
- Trigger: every read or traversal of an entity that matches the policy's
FORclause fires the block. (A query that doesn't touch the entity does not.) - Target of the writes: access metadata — a per-entity store keyed by entity ID, separate from
n.Properties. Readingn.accessCountinsideSETexpressions reads from this same metadata store. The node's stored properties are never mutated byON ACCESS. - Inspecting the writes:
policy(n)returns the access metadata;nornicdb.knowledgepolicy.resolve(...)includes effective values; a plainMATCH (n) RETURN nshows unchanged stored properties. - Timing: mutations are buffered by an access flusher and committed in batches. Reads immediately following an access may not yet observe the new counter.
- Read inside SET: the right-hand side can read existing access metadata (
n.accessCount,n.lastAccessedAt, ...), the entity's stored properties, and query parameters ($x) via the bind variable. - One mutation per
SET. Use multipleSETlines instead of comma-separated assignments.
What ON ACCESS does not do:
- It does not trigger or "tick" decay. Decay runs on every read regardless. ON ACCESS only updates the metadata that
LAST_ACCESSED-anchored decay reads. - It does not change
n.Properties. The node payload is untouched. - It does not return values.
SETmutates; the policy block has noRETURN.
Kalman-smoothed signals¶
Behavioral signals (clicks, dwell time, vote counts) are noisy and induce sycophantic feedback if applied raw. Wrap a SET with WITH KALMAN { ... } to smooth it:
APPLY {
ON ACCESS {
SET n.accessCount = coalesce(n.accessCount, 0) + 1
WITH KALMAN { q: 0.1, r: 88.0, varianceScale: 10.0, windowSize: 32 }
SET n.relevance = $observation
}
WHEN n.relevance > 0.7
APPLY PROFILE 'high_relevance'
}
Defaults applied when a key is omitted: - q = 0.1 — process noise (how fast the underlying value drifts) - r = 88.0 — measurement noise - varianceScale = 10.0 — auto-R sensitivity - windowSize = 32 — rolling window for variance estimation
Setting r explicitly switches the filter from auto-R mode to manual mode. Auto mode is recommended unless you have a known sensor model.
WHEN predicates¶
WHEN <expr> APPLY PROFILE '<name>' runs after ON ACCESS mutations are flushed, so predicates can reference the freshly-updated access fields:
WHEN n.accessCount >= 3 APPLY PROFILE 'reinforced'
WHEN n.lastAccessedAt > timestamp() - 3600000 APPLY PROFILE 'hot'
WHEN n.relevance > 0.7 AND n.tenantId = $tenant APPLY PROFILE 'tenant_high'
Multiple WHEN clauses are evaluated in declaration order; the first matching profile wins.
Targets¶
Promotion policies use the same FOR shapes as decay bindings:
FOR (n:Label) -- single label
FOR (n:Label1:Label2) -- all labels must match
FOR ()-[r:CO_ACCESSED]-() -- edge type
FOR () -- wildcard (catches everything else)
Common patterns¶
Reinforce after N accesses¶
CREATE PROMOTION PROFILE reinforced OPTIONS { multiplier: 1.5, scoreFloor: 0.20, scoreCap: 0.95 }
CREATE PROMOTION POLICY reinforce_after_three
FOR (n:Memory)
APPLY {
ON ACCESS { SET n.accessCount = coalesce(n.accessCount, 0) + 1 }
WHEN n.accessCount >= 3 APPLY PROFILE 'reinforced'
}
Dampen frequently-accessed (anti-sycophancy)¶
CREATE PROMOTION PROFILE access_dampener OPTIONS { multiplier: 0.5, scoreFloor: 0.0, scoreCap: 1.0 }
CREATE PROMOTION POLICY hot_path_dampening
FOR (n:Memory)
APPLY {
ON ACCESS {
SET n.accessCount = coalesce(n.accessCount, 0) + 1
SET n.lastAccessedAt = timestamp()
}
WHEN n.accessCount >= 5 APPLY PROFILE 'access_dampener'
}
Combined with an inverted decay profile (halfLifeSeconds: -86400, scoreFrom: 'LAST_ACCESSED'), this implements interference-driven forgetting: idle entries strengthen, frequently-accessed entries get pinned at half-strength.
Recency-weighted edge boost¶
CREATE PROMOTION PROFILE fresh_edge OPTIONS { multiplier: 2.0, scoreCap: 1.0, scope: 'EDGE' }
CREATE PROMOTION POLICY fresh_co_access
FOR ()-[r:CO_ACCESSED]-()
APPLY {
ON ACCESS {
SET r.traversalCount = coalesce(r.traversalCount, 0) + 1
SET r.lastTraversedAt = timestamp()
}
WHEN r.lastTraversedAt > timestamp() - 86400000 -- last 24h
APPLY PROFILE 'fresh_edge'
}
Tenant-gated boost¶
CREATE PROMOTION POLICY tenant_priority
FOR (n:Document)
APPLY {
WHEN n.tenantId = $priorityTenant AND n.kind = 'high_value'
APPLY PROFILE 'reinforced'
}
No ON ACCESS — pure score-time predicate.
Diagnostics¶
SHOW PROMOTION PROFILES
SHOW PROMOTION POLICIES
-- Combined catalog including profile vs policy rows
CALL nornicdb.knowledgepolicy.policies()
-- Effective resolution for an entity. Reports the resolved decay binding,
-- the targeted promotion policy, and the promotion profile NornicDB would
-- pick for this entity if every WHEN matched. Note: resolve() runs as a
-- dry-run with empty access metadata, so WHEN predicates that depend on
-- access counts may not reflect the live decision.
CALL nornicdb.knowledgepolicy.resolve('nornic:abc-123', '', '')
-- Inspect access metadata
MATCH (n:Memory {id: $id}) RETURN policy(n)
Lifecycle¶
ALTER PROMOTION PROFILE reinforced SET OPTIONS { multiplier: 1.75 }
ALTER PROMOTION POLICY reinforce_after_three DISABLE
ALTER PROMOTION POLICY reinforce_after_three ENABLE
DROP PROMOTION POLICY IF EXISTS reinforce_after_three
DROP PROMOTION PROFILE IF EXISTS reinforced
Drop the policy before the profile if both are going away — dropping a profile that policies still reference produces a validation error.
Gotchas¶
ON ACCESSmutations are eventually-consistent. Buffered in an access flusher and committed in batches. A read taken immediately after an access may not yet observe the new counter. Don't rely on it being synchronous.- A promotion policy is not required for decay to work. Create promotion only to (a) update
lastAccessedAtforLAST_ACCESSEDdecay, (b) track behavioral signals, or © conditionally apply a profile. ON ACCESSwrites go to access metadata, never ton.Properties. Inspect withpolicy(n)ornornicdb.knowledgepolicy.resolve(...).WITH KALMANonly smooths a single SET expression. Chain multiple Kalman blocks if you have multiple noisy fields.multiplier: 0.0collapses the promoted score to the promotionscoreFloor. To "disable" a profile,ALTERit (e.g. setmultiplier: 1.0) or drop the policy that references it.scoreCap < scoreFlooron a promotion profile is a misconfiguration. Clamping order is floor-then-cap, so the cap wins and the floor becomes inert.- A
WHENpredicate that references an undefined property returns null and is treated as not matching. Usecoalesce(n.foo, 0)to be explicit. - Multiple
WHENclauses are tried in declaration order; first match wins. Order from most-specific to most-general. ALTER PROMOTION POLICY ... ENABLE / DISABLEis a runtime toggle. A disabled policy still appears inSHOW PROMOTION POLICIESbut neither tracks access nor promotes scores.