Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/app/runtime-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export interface CoreRuntimeConfig {
entityGraphEnabled: boolean;
entitySearchMinSimilarity: number;
hybridSearchEnabled: boolean;
instructionBoostEnabled: boolean;
instructionBoostWeight: number;
iterativeRetrievalEnabled: boolean;
lessonsEnabled: boolean;
linkExpansionBeforeMMR: boolean;
Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ export interface RuntimeConfig {
rerankSkipMinGap: number;
literalListProtectionEnabled: boolean;
literalListProtectionMaxProtected: number;
/**
* EXP-05: when true, retrieval boosts memories tagged with
* `metadata.fact_role: 'instruction'` (set by extraction enrichment for
* imperative phrasing — "always X", "never Y", "from now on", etc.).
* Defaults-off per Sprint 2 rule. Tagging at ingest is unconditional;
* the boost itself is gated. See `src/services/instruction-boost.ts`.
*/
instructionBoostEnabled: boolean;
/** EXP-05: additive score boost applied to instruction-tagged results. */
instructionBoostWeight: number;
temporalQueryConstraintEnabled: boolean;
temporalQueryConstraintBoost: number;
deferredAudnEnabled: boolean;
Expand Down Expand Up @@ -372,6 +382,8 @@ export const config: RuntimeConfig = {
rerankSkipMinGap: parseFloat(optionalEnv('RERANK_SKIP_MIN_GAP') ?? '0.05'),
literalListProtectionEnabled: (optionalEnv('LITERAL_LIST_PROTECTION_ENABLED') ?? 'false') === 'true',
literalListProtectionMaxProtected: parsePositiveIntEnv('LITERAL_LIST_PROTECTION_MAX_PROTECTED', 3),
instructionBoostEnabled: (optionalEnv('INSTRUCTION_BOOST_ENABLED') ?? 'false') === 'true',
instructionBoostWeight: parseFloat(optionalEnv('INSTRUCTION_BOOST_WEIGHT') ?? '0.15'),
temporalQueryConstraintEnabled: (optionalEnv('TEMPORAL_QUERY_CONSTRAINT_ENABLED') ?? 'false') === 'true',
temporalQueryConstraintBoost: parseFloat(optionalEnv('TEMPORAL_QUERY_CONSTRAINT_BOOST') ?? '2'),
deferredAudnEnabled: (optionalEnv('DEFERRED_AUDN_ENABLED') ?? 'false') === 'true',
Expand Down Expand Up @@ -512,6 +524,8 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [
'rerankSkipTopSimilarity', 'rerankSkipMinGap',
// Literal/list answer selection
'literalListProtectionEnabled', 'literalListProtectionMaxProtected',
// Instruction-type retrieval boost (EXP-05)
'instructionBoostEnabled', 'instructionBoostWeight',
// Temporal query selection
'temporalQueryConstraintEnabled', 'temporalQueryConstraintBoost',
// Fast AUDN
Expand Down
2 changes: 2 additions & 0 deletions src/db/repository-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export const RESERVED_METADATA_KEYS = new Set<string>([
'clarification_note',
'target_memory_id',
'contradiction_confidence',
// Instruction tagging — `src/services/extraction-enrichment.ts` (EXP-05)
'fact_role',
]);

/**
Expand Down
73 changes: 72 additions & 1 deletion src/services/__tests__/extraction-enrichment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
*/

import { describe, expect, it } from 'vitest';
import { enrichExtractedFact } from '../extraction-enrichment.js';
import {
detectInstructionFact,
enrichExtractedFact,
} from '../extraction-enrichment.js';
import type { ExtractedFact } from '../extraction.js';

describe('enrichExtractedFact', () => {
Expand Down Expand Up @@ -76,3 +79,71 @@ function buildFact(factText: string, keywords: string[]): ExtractedFact {
relations: [],
};
}

describe('instruction tagging (EXP-05)', () => {
const INSTRUCTION_PHRASES = [
'Always respond in formal English.',
'Never share my email address.',
'From now on, summarize replies in three bullets.',
'Please remember that I prefer Celsius.',
'Make sure to cite sources for every claim.',
"Don't forget to greet me by name.",
'Every time I ask for code, include tests.',
'Whenever you mention prices, use USD.',
'Going forward, omit emoji from responses.',
'In the future, default to Python over JavaScript.',
'Remember to mention deployment caveats.',
];

for (const phrase of INSTRUCTION_PHRASES) {
it(`detects an imperative marker in: "${phrase}"`, () => {
expect(detectInstructionFact(phrase)).toBe(true);
});
}

it('does not flag a regular factual statement as an instruction', () => {
expect(detectInstructionFact('User is building a personal finance tracker.')).toBe(false);
expect(detectInstructionFact('User favorite color is blue.')).toBe(false);
});

it('tags fact metadata with fact_role=instruction and floors importance', () => {
const fact = buildFact('Always respond to me in formal English.', []);

const enriched = enrichExtractedFact(fact);

expect(enriched.metadata).toMatchObject({ fact_role: 'instruction' });
expect(enriched.importance).toBeGreaterThanOrEqual(0.95);
});

it('preserves importance above the floor when already higher', () => {
const fact = { ...buildFact('Never log my passwords.', []), importance: 0.99 };

const enriched = enrichExtractedFact(fact);

expect(enriched.importance).toBe(0.99);
expect(enriched.metadata?.fact_role).toBe('instruction');
});

it('does not add fact_role for non-imperative facts', () => {
const fact = buildFact('User is using Tailwind CSS for the finance tracker.', ['Tailwind CSS']);

const enriched = enrichExtractedFact(fact);

expect(enriched.metadata?.fact_role).toBeUndefined();
expect(enriched.importance).toBe(0.7);
});

it('merges with caller-supplied metadata without clobbering it', () => {
const fact: ExtractedFact = {
...buildFact('Always send me weekly summaries.', []),
metadata: { custom_tag: 'value' },
};

const enriched = enrichExtractedFact(fact);

expect(enriched.metadata).toMatchObject({
custom_tag: 'value',
fact_role: 'instruction',
});
});
});
156 changes: 156 additions & 0 deletions src/services/__tests__/instruction-boost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Unit tests for the EXP-05 instruction-type retrieval boost.
*
* Covers:
* - feature-flag off → no-op (returns input reference, no reorder).
* - feature-flag on → instruction-tagged results gain `boostWeight`.
* - feature-flag on → reorders results when boost overtakes a higher base score.
* - non-instruction results are untouched in score and identity.
* - empty input returns the empty reference unchanged.
* - weight is taken verbatim from config (additive, not multiplicative).
*/

import { describe, expect, it } from 'vitest';
import { applyInstructionBoost, type InstructionBoostConfig } from '../instruction-boost.js';
import { createSearchResult } from './test-fixtures.js';
import type { SearchResult } from '../../db/repository-types.js';

const QUERY = 'how should I respond?';

function makeInstructionResult(id: string, score: number): SearchResult {
return createSearchResult({
id,
content: `instruction memory ${id}`,
score,
similarity: score,
metadata: { fact_role: 'instruction' },
});
}

function makeRegularResult(id: string, score: number): SearchResult {
return createSearchResult({
id,
content: `regular memory ${id}`,
score,
similarity: score,
metadata: {},
});
}

describe('applyInstructionBoost', () => {
it('returns the input reference unchanged when the flag is off', () => {
const results = [makeInstructionResult('a', 0.4), makeRegularResult('b', 0.6)];
const config: InstructionBoostConfig = {
instructionBoostEnabled: false,
instructionBoostWeight: 0.15,
};

const out = applyInstructionBoost(results, QUERY, config);

expect(out).toBe(results);
expect(out.map((r) => r.id)).toEqual(['a', 'b']);
expect(out.map((r) => r.score)).toEqual([0.4, 0.6]);
});

it('adds the configured weight to instruction-tagged results', () => {
const results = [makeInstructionResult('a', 0.50), makeRegularResult('b', 0.40)];
const config: InstructionBoostConfig = {
instructionBoostEnabled: true,
instructionBoostWeight: 0.15,
};

const out = applyInstructionBoost(results, QUERY, config);

const a = out.find((r) => r.id === 'a');
const b = out.find((r) => r.id === 'b');
expect(a?.score).toBeCloseTo(0.65, 10);
expect(b?.score).toBe(0.40);
});

it('reorders results when the boost lifts an instruction past a higher-scored peer', () => {
const results = [makeRegularResult('regular', 0.60), makeInstructionResult('instr', 0.50)];
const config: InstructionBoostConfig = {
instructionBoostEnabled: true,
instructionBoostWeight: 0.15,
};

const out = applyInstructionBoost(results, QUERY, config);

expect(out.map((r) => r.id)).toEqual(['instr', 'regular']);
});

it('does not mutate the input array or its elements', () => {
const original = [makeInstructionResult('a', 0.5), makeRegularResult('b', 0.6)];
const snapshot = original.map((r) => ({ id: r.id, score: r.score }));
const config: InstructionBoostConfig = {
instructionBoostEnabled: true,
instructionBoostWeight: 0.2,
};

applyInstructionBoost(original, QUERY, config);

expect(original.map((r) => ({ id: r.id, score: r.score }))).toEqual(snapshot);
});

it('is a no-op for an empty input list', () => {
const empty: SearchResult[] = [];
const config: InstructionBoostConfig = {
instructionBoostEnabled: true,
instructionBoostWeight: 0.15,
};

const out = applyInstructionBoost(empty, QUERY, config);

expect(out).toBe(empty);
});

it('respects a weight of 0 (no effective change in scores)', () => {
const results = [makeInstructionResult('a', 0.5), makeRegularResult('b', 0.4)];
const config: InstructionBoostConfig = {
instructionBoostEnabled: true,
instructionBoostWeight: 0,
};

const out = applyInstructionBoost(results, QUERY, config);

expect(out.find((r) => r.id === 'a')?.score).toBe(0.5);
expect(out.find((r) => r.id === 'b')?.score).toBe(0.4);
expect(out.map((r) => r.id)).toEqual(['a', 'b']);
});

it('skips results whose metadata.fact_role is not "instruction"', () => {
const results = [
createSearchResult({ id: 'other-role', score: 0.5, metadata: { fact_role: 'observation' } }),
makeRegularResult('plain', 0.6),
];
const config: InstructionBoostConfig = {
instructionBoostEnabled: true,
instructionBoostWeight: 0.5,
};

const out = applyInstructionBoost(results, QUERY, config);

expect(out.find((r) => r.id === 'other-role')?.score).toBe(0.5);
expect(out.find((r) => r.id === 'plain')?.score).toBe(0.6);
});

it('boosts every instruction-tagged result, not just the first', () => {
const results = [
makeInstructionResult('i1', 0.30),
makeInstructionResult('i2', 0.20),
makeRegularResult('r1', 0.50),
];
const config: InstructionBoostConfig = {
instructionBoostEnabled: true,
instructionBoostWeight: 0.10,
};

const out = applyInstructionBoost(results, QUERY, config);

expect(out.find((r) => r.id === 'i1')?.score).toBeCloseTo(0.40, 10);
expect(out.find((r) => r.id === 'i2')?.score).toBeCloseTo(0.30, 10);
expect(out.find((r) => r.id === 'r1')?.score).toBe(0.50);
// r1 still wins (0.50 > 0.40), but both instructions retain new scores.
expect(out.map((r) => r.id)).toEqual(['r1', 'i1', 'i2']);
});
});
51 changes: 50 additions & 1 deletion src/services/extraction-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,32 @@ const WORKS_ON_MARKERS = ['building', 'working on', 'started', 'created', 'built
const KNOWS_MARKERS = ['advisor', 'career advice from', 'supportive', 'recommendation letter', 'recommended by'];
const STUDIES_MARKERS = ['focus on', 'studying', 'research', 'paper on'];

/**
* Imperative phrasing markers that flag an extracted fact as an explicit
* instruction. Detection is intentionally conservative — false positives on
* assistant tutorial content are acceptable since extraction prefixes those
* with "Assistant mentioned:". When a marker hits, the fact gains
* `metadata.fact_role: 'instruction'` and its importance is floored to 0.95
* so lifecycle decay does not evict it.
*/
const INSTRUCTION_MARKERS = [
'always ',
'never ',
'from now on',
'please remember',
'make sure to',
"don't forget",
'do not forget',
'every time',
'whenever you',
'going forward',
'in the future',
'remember to',
] as const;

/** Floor for instruction-tagged importance — see plan EXP-05 risks (2). */
const INSTRUCTION_IMPORTANCE_FLOOR = 0.95;

const TOOL_NAMES = new Set([
'React',
'TypeScript',
Expand Down Expand Up @@ -70,7 +96,30 @@ export function enrichExtractedFact(fact: ExtractedFact): ExtractedFact {
...inferRelations(fact.fact, withSelf),
]);

return { ...fact, entities: withSelf, relations };
return applyInstructionTagging({ ...fact, entities: withSelf, relations });
}

/**
* Detect imperative-phrased facts (always/never/from-now-on/...) and tag
* them with `metadata.fact_role: 'instruction'`. Floors importance to
* INSTRUCTION_IMPORTANCE_FLOOR so lifecycle decay preserves them.
*
* Pure: returns the input unchanged when no marker matches.
*/
function applyInstructionTagging(fact: ExtractedFact): ExtractedFact {
if (!detectInstructionFact(fact.fact)) return fact;
const existingMetadata = fact.metadata ?? {};
return {
...fact,
importance: Math.max(fact.importance, INSTRUCTION_IMPORTANCE_FLOOR),
metadata: { ...existingMetadata, fact_role: 'instruction' },
};
}

/** Returns true when the fact text contains an imperative instruction marker. */
export function detectInstructionFact(text: string): boolean {
const lower = ` ${text.toLowerCase()} `;
return INSTRUCTION_MARKERS.some((marker) => lower.includes(marker));
}

function shouldAddSelfEntity(text: string): boolean {
Expand Down
8 changes: 8 additions & 0 deletions src/services/extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ export interface ExtractedFact {
network?: 'world' | 'experience' | 'opinion' | 'observation';
/** Opinion confidence [0,1], only set when network='opinion'. */
opinionConfidence?: number | null;
/**
* Optional structured metadata propagated to the persisted memory's
* `metadata` map. Used by post-extraction enrichers (e.g. instruction
* tagging in `extraction-enrichment.ts`) to attach retrieval-time signals
* without inflating the core extraction surface. Reserved keys are
* enforced by the `RESERVED_METADATA_KEYS` drift test.
*/
metadata?: Record<string, unknown>;
}

export type AUDNAction = 'ADD' | 'UPDATE' | 'DELETE' | 'SUPERSEDE' | 'NOOP' | 'CLARIFY';
Expand Down
Loading
Loading