@agencer/learning-loop
v0.5.0
Published
TITW Learning Loop. Exact + semantic + Composer recipe matching with Opus-driven distillation. Day 148 four-layer architecture.
Maintainers
Readme
@agencer/learning-loop
TITW Learning Loop. Exact + semantic + Composer recipe matching with Opus-driven distillation. The Day 148 four-layer architecture extracted from agencer-ox into a standalone, hot-swappable Lego.
The Lego short-circuits the agent pipeline when a previously-distilled recipe matches the current request. A hit returns a cached response; a miss falls through to the full pipeline and may produce a new recipe via distillation.
Status
v0.2.0. Extraction arc complete (Legs E-1 through E-5). README, Dockerfile, and Express harness make the Lego black-box reviewable in isolation. Published to npm under @agencer/learning-loop; consumable by agencer-brain, future operatives, and external integrators.
Architecture
Four matching layers, in cost-ascending order. Layer A is this Lego's public facade; the consumer dispatches on the returned HitResult.kind. Layer B is the provider implementations (pgvector for storage, Anthropic Haiku for Composer, Anthropic Opus for distillation). Layer C is the consumer composition root that selects providers and threads config.
┌──────────────────────────────────────────────┐
│ Consumer: lego.tryHit(rawText, ctx) │
└────────────────────┬─────────────────────────┘
│
┌─────────────────────────────────▼─────────────────────────────────┐
│ Layer 1: Exact match (titw_recipes.problem_signature, SHA256) │
│ Cost: ~1 Postgres lookup. Returns ExactHit (kind: "exact"). │
└─────────────────────────────────┬─────────────────────────────────┘
│ miss
┌─────────────────────────────────▼─────────────────────────────────┐
│ Layer 2: Semantic match (pgvector cosine, threshold default │
│ 0.85). Returns SemanticHit (kind: "semantic"). │
│ Cost: ~1 pgvector query. │
└─────────────────────────────────┬─────────────────────────────────┘
│ miss
┌─────────────────────────────────▼─────────────────────────────────┐
│ Layer 3: Composer soft-judge (Haiku reads K near-miss │
│ candidates in [floor, threshold), picks one or escalates).│
│ Cost: one Haiku call (titw.composer.attempt ledger row). │
│ Returns ComposerHit (kind: "composed") on "use", │
│ null on "escalate" (escalate row written; no consumer delivery). │
└─────────────────────────────────┬─────────────────────────────────┘
│ escalate / no candidates
┌─────────────────────────────────▼─────────────────────────────────┐
│ Escalate to full pipeline (caller-owned). │
│ After pipeline completes, the consumer seals the envelope and │
│ calls lego.distillEnvelope(envelope) to learn a new recipe. │
└───────────────────────────────────────────────────────────────────┘The four-layer split is deliberate: Layer 1 is free, Layer 2 is cheap, Layer 3 pays Haiku spend on a measurable budget, and only Layer 4 incurs full agent cost. The Lego's job is to push as many turns as safely possible into Layers 1 through 3.
See src/factory.ts:1-64 for the layer-by-layer rationale behind side effects, observability parity with the pre-extraction imperative ladder, and the F1/F2 deferral closures landed in Leg E-3e.
Quickstart
import { Pool } from "pg";
import pino from "pino";
import { UsageAccountant } from "@agencer/usage-accountant";
import { createLearningLoop, TitwService, runMigrations } from "@agencer/learning-loop";
const logger = pino();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await runMigrations(pool, logger);
const titwService = new TitwService(pool, logger);
const usageAccountant = new UsageAccountant(db, logger); // db: better-sqlite3.Database
const lego = createLearningLoop(
{ titwService, usageAccountant, logger },
{ semanticThreshold: 0.85, composerFloor: 0.55 },
);
const hit = await lego.tryHit(userText, { userId, sessionId, apiKey });
if (hit) {
await deliver(hit.cachedResponse);
lego.commit(hit, { userId, sessionId, apiKey });
}Facade interface
The LearningLoop returned by createLearningLoop has four members:
| Member | Signature | Notes |
|---|---|---|
| tryHit | (rawText: string, ctx: TryHitContext) => Promise<HitResult \| null> | Runs the brain-gate ladder. Returns the hit shape on Layer-1 or Layer-3, null on miss / kill-switch / escalate. NEVER throws. Does NOT write hit ledger rows (deferred to commit). |
| commit | (hit: HitResult, ctx: TryHitContext) => void | Call AFTER consumer-side delivery succeeds. Writes the hit ledger row, fires fire-and-forget reinforcePrinciple, emits the [BRAIN GATE] log marker. NEVER throws. Not idempotent. |
| distillEnvelope | (args: DistillEnvelopeArgs) => Promise<DistillResult> | Opus-driven recipe distillation from a sealed envelope. Identity passthrough of the standalone distillEnvelope export. |
| service | readonly TitwService | The underlying storage service. Exposed for flows outside the brain-gate ladder (chat-path routing, distillation-trigger background jobs). |
The delivery-gated tryHit / commit split exists so that the ledger only counts hits that the consumer actually served. If consumer-side delivery throws after tryHit returns a hit, omit commit and the ledger does not over-count.
Construction
function createLearningLoop(
deps: LearningLoopDeps,
config?: LearningLoopConfig,
): LearningLoop;LearningLoopDeps:
| Field | Type | Required | Notes |
|---|---|---|---|
| titwService | TitwService | Yes | Recipe storage. Construct via new TitwService(pgPool, logger, options?). |
| usageAccountant | UsageAccountant | Yes | Metered ledger sink. Provided by @agencer/usage-accountant. |
| logger | pino.Logger | Yes | Structured logger. The facade emits [BRAIN GATE] info lines on every hit and escalate. |
LearningLoopConfig (all optional; consumer resolves env knobs and threads values):
| Field | Type | Default | Maps to env |
|---|---|---|---|
| semanticThreshold | number (0..1) | DEFAULT_SEARCH_THRESHOLD = 0.85 | TITW_SEMANTIC_THRESHOLD |
| composerFloor | number (0..1) | DEFAULT_COMPOSER_FLOOR = 0.55 | TITW_COMPOSER_FLOOR |
| composerMinConfidence | number (0..1) | DEFAULT_COMPOSER_MIN_CONFIDENCE = 0.5 | TITW_COMPOSER_MIN_CONFIDENCE |
| composerTimeoutMs | number (> 0) | DEFAULT_COMPOSER_TIMEOUT_MS = 3000 | TITW_COMPOSER_TIMEOUT_MS |
| composerModel | string | DEFAULT_COMPOSER_MODEL = claude-haiku-4-5-20251001 | TITW_COMPOSER_MODEL |
| composerDisabled | boolean | false | TITW_COMPOSER_DISABLED |
| nearMissLoggingEnabled | boolean | false | TITW_NEARMISS_LOGGING |
| timeoutMs | number (> 0) | TITW_LOOKUP_TIMEOUT_MS = 2000 | (no env; per-call override only) |
Hit shapes
type HitResult = ExactHit | SemanticHit | ComposerHit;
interface ExactHit { kind: "exact"; recipe: TitwRecipe; cachedResponse: string; }
interface SemanticHit { kind: "semantic"; recipe: TitwRecipe; cachedResponse: string; similarity?: number; }
interface ComposerHit {
kind: "composed";
recipeId: string;
cachedResponse: string;
similarity?: number;
reasoning: string;
confidence: number;
adaptations?: Record<string, string>;
}Discriminate on hit.kind. The full TitwRecipe object accompanies Layer-1 hits (exact + semantic); Composer hits carry recipeId only because the Composer's "use" decision references a candidate that was already loaded by tryComposerCandidates and does not re-fetch.
Per-call context
interface TryHitContext {
userId: string;
sessionId: string;
apiKey: string;
logFields?: Record<string, unknown>;
}apiKey is the Anthropic API key for Composer. An empty string disables Composer (the call short-circuits to escalate). The key MUST flow per-call rather than being cached at construct time so vault rotation takes effect immediately.
logFields are spread into every [BRAIN GATE] log line. Consumers preserve their dashboard schema (for example {projectId}) without baking consumer vocabulary into the Lego. The facade's own named fields win on collision.
Integration example
A complete worked example, with all four tryHit return branches handled, lives at harness/example.ts and is compile-checked under the harness build. The condensed version:
import { Pool } from "pg";
import pino from "pino";
import { UsageAccountant } from "@agencer/usage-accountant";
import {
createLearningLoop,
TitwService,
runMigrations,
type HitResult,
} from "@agencer/learning-loop";
const logger = pino();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await runMigrations(pool, logger);
const titwService = new TitwService(pool, logger);
const usageAccountant = new UsageAccountant(db, logger); // db: better-sqlite3.Database
const lego = createLearningLoop(
{ titwService, usageAccountant, logger },
{
semanticThreshold: 0.85,
composerFloor: 0.55,
composerDisabled: process.env.TITW_COMPOSER_DISABLED === "true",
},
);
async function handleTurn(rawText: string, ctx: { userId: string; sessionId: string; apiKey: string }) {
const hit: HitResult | null = await lego.tryHit(rawText, ctx);
if (hit === null) {
return runFullPipeline(rawText, ctx);
}
switch (hit.kind) {
case "exact":
case "semantic":
await deliver(hit.cachedResponse);
break;
case "composed":
await deliver(hit.cachedResponse);
break;
}
lego.commit(hit, ctx);
}Ledger components reference
Every metered call writes one row to UsageAccountant. The table below covers the full surface exported from the Lego barrel.
Hit and escalate (Layer-1 / Layer-3 lookup path)
| Constant | Component string | Cost | Triggered by |
|---|---|---|---|
| COMP_TITW_RECIPE_HIT_EXACT | titw.recipe_hit.exact | $0 | commit() after Layer-1 exact hit |
| COMP_TITW_RECIPE_HIT_SEMANTIC | titw.recipe_hit.semantic | $0 | commit() after Layer-1 semantic hit |
| COMP_TITW_RECIPE_HIT (deprecated alias of _EXACT) | titw.recipe_hit.exact | $0 | Back-compat for legacy external readers |
| COMP_TITW_COMPOSER_ATTEMPT | titw.composer.attempt | Haiku spend | composeJudgment unconditionally on every Composer fire |
| COMP_TITW_COMPOSER_HIT | titw.composer.hit | $0 | commit() after Composer decision = "use" |
| COMP_TITW_COMPOSER_ESCALATE | titw.composer.escalate | $0 | tryHit on Composer decision = "escalate" (no consumer delivery) |
Distillation (Opus-driven recipe learning)
| Constant | Component string | Cost | Triggered by |
|---|---|---|---|
| COMP_RECIPE_DISTILLATION_OPUS | recipe.distillation.opus | Opus spend | Successful distillEnvelope call that reaches Opus |
| COMP_RECIPE_DISTILLATION_SKIPPED_GATE_FAILED | recipe.distillation.skipped.gate_failed | $0 | Envelope rejected by the positive-signal gate before any Opus call |
| COMP_RECIPE_DISTILLATION_SKIPPED_OPUS_ERROR | recipe.distillation.skipped.opus_error | $0 | Opus call errored (network, auth, rate-limit, server) OR TitwService.recordPrinciple threw while persisting the distilled recipe |
| COMP_RECIPE_DISTILLATION_SKIPPED_VALIDATION_ERROR | recipe.distillation.skipped.validation_error | $0 | Opus response failed JSON-schema validation |
| COMP_RECIPE_DISTILLATION_SKIPPED_SAFETY_CONCERN | recipe.distillation.skipped.safety_concern | $0 | Opus declared a safety concern in its structured output |
| COMP_RECIPE_DISTILLATION_SKIPPED_LOW_CONFIDENCE | recipe.distillation.skipped.low_confidence | $0 | Opus confidence below the distillation floor |
| COMP_RECIPE_DISTILLATION_SKIPPED_EMPTY_RESPONSE | recipe.distillation.skipped.empty_response | $0 | Opus returned a valid DistillationOutput whose positive_response field is an empty string |
Operator audit query: cost per pipeline run saved by Composer =
sum(cost_usd) WHERE component = 'titw.composer.attempt' divided by
count(*) WHERE component = 'titw.composer.hit'.
Env-var reference
The Lego itself never reads process.env. The consumer composition root resolves these and threads values into LearningLoopConfig (see packages/server/src/config/learning-loop-env.ts for the canonical reader pattern).
| Env var | Type | Range | Default | Maps to LearningLoopConfig |
|---|---|---|---|---|
| TITW_SEMANTIC_THRESHOLD | float | [0, 1] | 0.85 | semanticThreshold |
| TITW_NEARMISS_LOGGING | bool | true / 1 / yes | false | nearMissLoggingEnabled |
| TITW_COMPOSER_FLOOR | float | [0, 1] | 0.55 | composerFloor |
| TITW_COMPOSER_TIMEOUT_MS | int | > 0 | 3000 | composerTimeoutMs |
| TITW_COMPOSER_MODEL | string | non-empty | claude-haiku-4-5-20251001 | composerModel |
| TITW_COMPOSER_MIN_CONFIDENCE | float | [0, 1] | 0.5 | composerMinConfidence |
| TITW_COMPOSER_DISABLED | bool | true / 1 / yes | false | composerDisabled |
Invalid values (out-of-range, non-numeric, empty after trim) fall back to the documented default at consumer parse time. There is no module-load env read inside the Lego.
Standalone barrel exports
Beyond the facade, the package barrel re-exports the underlying primitives so consumers can compose flows outside the brain-gate ladder.
| Export | Purpose |
|---|---|
| TitwService, TitwRecipe, TitwServiceOptions, NearMiss | Storage substrate. Construct via new TitwService(pgPool, logger, options?). |
| runMigrations(pool, logger) | Idempotent SQL migrations for the titw_recipes table. Run once at startup. |
| normalizeProblemText(text) | Free function. Lowercase + outer-punctuation trim + collapse whitespace. Idempotent. Used internally by tryHit and distillEnvelope. |
| tryRecipeHit(opts), tryComposerCandidates(opts) | The Layer-1 and Layer-3 lookup helpers. Exposed for tests and adapters that need finer granularity than tryHit. |
| composeJudgment(...), buildComposerUserPrompt(...), COMPOSER_SYSTEM_PROMPT | Composer primitives. Used internally by tryHit; exported for fine-tune corpus construction. |
| distillEnvelope(args), validateDistillation(json) | Distillation primitives. The facade's distillEnvelope member is an identity passthrough of the standalone function. |
| LearningLoopError, LearningLoopErrorCode | Lego-owned error taxonomy (auth, network, rate_limit, server, parse, refusal, unknown). |
| RecipeEnvelope, RecipeSignalKind, RecipeOpusProvider, RecipeFinalMessage | Envelope shape and provider contract for distillation. |
| DistillResult, DistillSkippedReason, DistillErrorKind, DistillationCategory, DistillationOutput | Distillation result types. |
| POSITIVE_SIGNAL_KINDS, DISTILLATION_SCHEMA_VERSION | Constants for the distillation gate and schema-version verbatim assertion. |
| DEFAULT_SEARCH_THRESHOLD, DEFAULT_COMPOSER_FLOOR, DEFAULT_COMPOSER_MODEL, DEFAULT_COMPOSER_TIMEOUT_MS, DEFAULT_COMPOSER_MAX_TOKENS, DEFAULT_COMPOSER_TEMPERATURE, DEFAULT_COMPOSER_MIN_CONFIDENCE, TITW_LOOKUP_TIMEOUT_MS | Default values for the config fields above. |
| PACKAGE_VERSION | String constant for runtime version assertions. |
How to test in isolation
# From the monorepo root:
npm run build --workspace=@agencer/learning-loop
npm run test --workspace=@agencer/learning-loopThe Lego's test suite lives at packages/learning-loop/__tests__/:
factory-tryhit.test.ts: facade behavior (all four return branches, kill switch, escalate path, log-field threading).composer.test.ts,composer-integration.test.ts: Composer soft-judge unit and integration coverage.distiller.test.ts: Opus envelope distillation, gate, validation, skip subtypes.types.test.ts: type-level assertions for the Lego-owned vocabulary.
TitwService integration tests require a Postgres instance with the pgvector extension. Set TEST_DATABASE_URL to a disposable database; the test harness runs migrations on startup and tears down test rows on teardown. The isolation guard at the repo root (commit 50670779) ensures these tests never touch a production database.
How to build the harness
# From the monorepo root:
docker build --file packages/learning-loop/Dockerfile .
docker build --file packages/learning-loop/harness/Dockerfile.harness .
docker run --rm -p 3030:3030 <harness image id>
# Smoke:
curl http://localhost:3030/health
curl -X POST http://localhost:3030/tryHit \
-H 'content-type: application/json' \
-d '{"rawText":"explain how to deploy"}'The harness wraps the Lego with a four-endpoint Express server backed by a mocked TitwService. It exists for black-box review: an external dev can spin up the Lego in isolation and curl it without provisioning Postgres or pgvector. See harness/README.md for the full endpoint contract and curl recipes.
Both Dockerfiles use the monorepo root as build context (so workspace dependencies resolve at build time). After Leg E-5 published @agencer/[email protected] to npm, external consumers install from the registry instead of bind-mounting the workspace.
Sacred constraints
Per Canon Law 8, the following invariants are sacred. Changes require the explicit Learning Loop change protocol (test harness run + verification + operator sign-off):
- Delivery-gated ledger ordering.
tryHitMUST NOT writetitw.recipe_hit.*ortitw.composer.hitledger rows and MUST NOT callreinforcePrinciple. Those side effects are deferred tocommit, which the consumer calls AFTER successful delivery. Violations cause the ledger to count hits that were never served. The escalate ledger row IS written insidetryHitbecause there is no consumer-side delivery for escalate. - Per-call
apiKeythreading. The Anthropic API key for Composer flows throughTryHitContexton every call. The Lego MUST NOT cache it at construct time. Vault rotation must take effect immediately on the next turn. - No
process.envreads inside the Lego. The Lego accepts resolved config values viaLearningLoopConfig. The consumer composition root owns env parsing.
License
Proprietary. See LICENSE.
