Skip to content

Commit 39d5660

Browse files
authored
refactor(semantic-cache): drop redundant B3 read-time threshold layer (#151)
PR #134's earlier B3 commit added a 5s-TTL read-time override (HGETALL on each check()) and PR #148's commit added a 30s background refresh that mutates defaultThreshold/categoryThresholds in-place. Both read the same {prefix}:__config hash; running both is duplicated work and the file even ended up with a duplicate `private readonly configKey: string` field declaration. Keep the 30s background-refresh approach (cleaner lifecycle, opt-out flag, prometheus counter, no per-call overhead) and delete the B3 machinery: - Removes private fields thresholdOverrides, thresholdOverridesCachedAt, thresholdOverridesRefresh and the THRESHOLD_OVERRIDES_TTL_MS constant. - Removes private helpers resolveThreshold, getThresholdOverrides, refreshThresholdOverrides. - Restores check()/checkBatch() threshold resolution to the simple options.threshold > categoryThresholds[category] > defaultThreshold chain; refreshConfig() updates those mutable fields. - Deletes runtime-threshold-overrides.test.ts (covered the deleted helpers). - Removes the duplicate configKey field declaration and constructor assignment. - CHANGELOG: drop the read-time-overrides bullet, expand the periodic-refresh bullet to spell out hash field semantics and the synchronous-first-tick guarantee, and reword the Behavior change note. Tests: 128/128 pass. Trade-off: propagation goes from ~5s to ~30s worst-case, which is acceptable given the human-approval flow upstream.
1 parent 54d899a commit 39d5660

3 files changed

Lines changed: 21 additions & 315 deletions

File tree

packages/semantic-cache/CHANGELOG.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **Periodic config refresh**`SemanticCache` polls `{name}:__config` on a
1313
configurable interval (default 30s) and updates `defaultThreshold` and
14-
`categoryThresholds` in-memory. Configure via the new `configRefresh` option;
15-
opt out with `configRefresh: { enabled: false }`. New Prometheus counter
14+
`categoryThresholds` in-memory. The first refresh fires synchronously on
15+
`initialize()` so a freshly-started process picks up an already-applied
16+
proposal without waiting for the first tick. Configure via the new
17+
`configRefresh` option; opt out with `configRefresh: { enabled: false }`.
18+
Hash field semantics: `threshold``defaultThreshold`,
19+
`threshold:{category}``categoryThresholds[category]`; out-of-range values
20+
(`< 0`, `> 2`, NaN) are ignored. New Prometheus counter
1621
`{prefix}_config_refresh_failed_total`.
17-
- **Runtime threshold overrides**`check()` and `checkBatch()` also read
18-
`{prefix}:__config` on each call (cached for 5s in-process) and honor
19-
`threshold` / `threshold:{category}` fields. Resolution order:
20-
`options.threshold` > runtime override > `categoryThresholds` > `defaultThreshold`.
21-
Read failures fall back silently to constructor values; out-of-range values
22-
(`< 0`, `> 2`, NaN) are dropped.
2322
- **`refreshConfig()`** — public method returning `boolean` for manual refresh.
2423
- **`threshold_adjust` capability** — added to the discovery marker's
2524
`capabilities` array. Monitor's apply dispatcher gates on this before writing
@@ -33,8 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3332

3433
### Behavior change
3534

36-
- A `{prefix}:__config` Valkey hash that previously had no effect now influences
37-
`check()` thresholds. Audit existing keys before upgrading.
35+
- A `{prefix}:__config` Valkey hash that previously had no effect now drives
36+
`defaultThreshold` and `categoryThresholds` at runtime. Audit existing keys
37+
before upgrading, or set `configRefresh: { enabled: false }` to keep the
38+
constructor values authoritative.
3839

3940
## [0.3.0] - 2026-04-27
4041

packages/semantic-cache/src/SemanticCache.ts

Lines changed: 10 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import {
4040
} from './discovery';
4141

4242
const INVALIDATE_BATCH_SIZE = 1000;
43-
const THRESHOLD_OVERRIDES_TTL_MS = 5_000;
4443

4544
const PACKAGE_VERSION = (require('../package.json') as { version: string }).version;
4645

@@ -67,7 +66,6 @@ export class SemanticCache {
6766
private readonly embeddingCacheTtl: number;
6867
private readonly embedKeyPrefix: string;
6968
private readonly discoveryOptions: DiscoveryOptions;
70-
private readonly configKey: string;
7169
private readonly _initialDefaultThreshold: number;
7270
private readonly _initialCategoryThresholds: Record<string, number>;
7371
private readonly configRefreshOptions: Required<ConfigRefreshOptions>;
@@ -80,10 +78,6 @@ export class SemanticCache {
8078
private _initPromise: Promise<void> | null = null;
8179
private _initGeneration = 0;
8280

83-
private thresholdOverrides: { global?: number; byCategory: Record<string, number> } | null = null;
84-
private thresholdOverridesCachedAt = 0;
85-
private thresholdOverridesRefresh: Promise<void> | null = null;
86-
8781
private readonly analyticsOpts: SemanticCacheOptions['analytics'];
8882
private readonly usesDefaultCostTable: boolean;
8983
private analytics: Analytics = NOOP_ANALYTICS;
@@ -143,9 +137,6 @@ export class SemanticCache {
143137
this._initialDefaultThreshold = this.defaultThreshold;
144138
this._initialCategoryThresholds = { ...this.categoryThresholds };
145139

146-
// Config-hash key matches the discovery marker's config_key field
147-
this.configKey = `${this.name}:__config`;
148-
149140
// Refresh options
150141
const refresh = options.configRefresh ?? {};
151142
this.configRefreshOptions = {
@@ -254,8 +245,11 @@ export class SemanticCache {
254245

255246
return this.traced('check', async (span) => {
256247
const category = options?.category ?? '';
257-
const overrides = await this.getThresholdOverrides();
258-
const threshold = this.resolveThreshold(category, options?.threshold, overrides);
248+
const threshold =
249+
options?.threshold ??
250+
(category && this.categoryThresholds[category] !== undefined
251+
? this.categoryThresholds[category]
252+
: this.defaultThreshold);
259253

260254
// Resolve text and binary refs from prompt
261255
const { text: promptText, binaryRefs } = await this.resolvePrompt(prompt);
@@ -626,8 +620,11 @@ export class SemanticCache {
626620
const embeddings = await Promise.all(resolved.map(({ text }) => this.embed(text)));
627621

628622
const category = options?.category ?? '';
629-
const overrides = await this.getThresholdOverrides();
630-
const threshold = this.resolveThreshold(category, options?.threshold, overrides);
623+
const threshold =
624+
options?.threshold ??
625+
(category && this.categoryThresholds[category] !== undefined
626+
? this.categoryThresholds[category]
627+
: this.defaultThreshold);
631628
const k = options?.k ?? 1;
632629
const userFilter = options?.filter;
633630

@@ -1412,85 +1409,6 @@ export class SemanticCache {
14121409
);
14131410
}
14141411

1415-
// -- Runtime threshold overrides --
1416-
//
1417-
// Reads { prefix }:__config to honor approved cache-intelligence threshold_adjust
1418-
// proposals at runtime. See docs/plans/specs/spec-semantic-cache-runtime-threshold-reads.md.
1419-
1420-
private resolveThreshold(
1421-
category: string,
1422-
optionsThreshold: number | undefined,
1423-
overrides: { global?: number; byCategory: Record<string, number> },
1424-
): number {
1425-
if (optionsThreshold !== undefined) {
1426-
return optionsThreshold;
1427-
}
1428-
if (category && overrides.byCategory[category] !== undefined) {
1429-
return overrides.byCategory[category];
1430-
}
1431-
if (overrides.global !== undefined) {
1432-
return overrides.global;
1433-
}
1434-
if (category && this.categoryThresholds[category] !== undefined) {
1435-
return this.categoryThresholds[category];
1436-
}
1437-
return this.defaultThreshold;
1438-
}
1439-
1440-
private async getThresholdOverrides(): Promise<{
1441-
global?: number;
1442-
byCategory: Record<string, number>;
1443-
}> {
1444-
const fresh =
1445-
this.thresholdOverrides !== null &&
1446-
Date.now() - this.thresholdOverridesCachedAt < THRESHOLD_OVERRIDES_TTL_MS;
1447-
if (fresh) {
1448-
return this.thresholdOverrides!;
1449-
}
1450-
if (!this.thresholdOverridesRefresh) {
1451-
this.thresholdOverridesRefresh = this.refreshThresholdOverrides().finally(() => {
1452-
this.thresholdOverridesRefresh = null;
1453-
});
1454-
}
1455-
await this.thresholdOverridesRefresh;
1456-
return this.thresholdOverrides ?? { byCategory: {} };
1457-
}
1458-
1459-
private async refreshThresholdOverrides(): Promise<void> {
1460-
let hash: Record<string, string>;
1461-
try {
1462-
hash = await this.client.hgetall(this.configKey);
1463-
} catch (err: unknown) {
1464-
// Fail open: keep prior cache (if any) and let resolution fall back to
1465-
// constructor categoryThresholds. Logged so operators can spot a degraded
1466-
// Valkey config-hash read without breaking the user-facing check() path.
1467-
console.warn(
1468-
`[semantic-cache:${this.name}] failed to read ${this.configKey}: ${errMsg(err)}`,
1469-
);
1470-
return;
1471-
}
1472-
1473-
const next: { global?: number; byCategory: Record<string, number> } = { byCategory: {} };
1474-
for (const [field, value] of Object.entries(hash)) {
1475-
const parsed = parseFloat(value);
1476-
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 2) {
1477-
console.warn(
1478-
`[semantic-cache:${this.name}] config-hash override out of range: ${field}=${value}`,
1479-
);
1480-
continue;
1481-
}
1482-
if (field === 'threshold') {
1483-
next.global = parsed;
1484-
} else if (field.startsWith('threshold:')) {
1485-
const cat = field.slice('threshold:'.length);
1486-
if (cat) {
1487-
next.byCategory[cat] = parsed;
1488-
}
1489-
}
1490-
}
1491-
this.thresholdOverrides = next;
1492-
this.thresholdOverridesCachedAt = Date.now();
1493-
}
14941412

14951413
private parseDimensionFromInfo(info: unknown[]): number {
14961414
for (let i = 0; i < info.length - 1; i += 2) {

0 commit comments

Comments
 (0)