R3 slice 1 (research-deepening). Stop discarding the relevance judgment the
trend-spotter already computes: persist a 4-field TrendScore {mode, dimensions,
composite, priority} on TrendRecord (schema v2->v3, additive lossless migrate),
computed by the existing score.ts composite()+band() (one owner, no new arithmetic),
threaded item->store; then rankForBrief sorts each bucket composite-first (sentinel
-1 for unscored) and renderBrief surfaces "· <priority> (<mode>)" per body entry
(briefSummary shows the band only). First-sight only; mode-blind ranking with the mode
shown so the operator can disambiguate instruments.
- score.ts: TrendScore + requiredDimensions(mode) (ordered) + scoreEnvelope (composes
composite+band; throws on bad dim by contract)
- types.ts: SCHEMA_VERSION 2->3; TrendRecord.score?
- store.ts: TrendInput.score?; addTrend persists first-sight (duplicate keeps it);
migrate comment v1->v2->v3 (logic unchanged, JSON.stringify preserves the field)
- item.ts: TrendItem.score?; normalizeItem validates (non-array score/dimensions + the
mode's five dims in [1,10]) -> structured error never throw, carries validated dims;
itemToInput -> scoreEnvelope (no throw on the capture path; direct call throws by contract)
- brief.ts: composite-primary comparator; band+mode render; exact ranking: descriptor
- cli.ts: capture persists score via itemToInput (doc-only); add/score paths unchanged
- agents/trend-spotter.md Step 4.5: capture batch carries the Step-2 dimensions
- gate: TRENDS_TESTS_FLOOR 104->146; new unconditional Section 16j; ASSERT floor 94->99
Tests: trends 146/146 (RED two-phase: logic-RED store/brief/cli; stub-first then
assertion-RED score/item). Gate green (Passed 114 / Failed 0; 113 checks >= 99).
Hook suite 139/139 untouched. Counts 27/19/29 unchanged. No new source file/agent/command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
191 lines
7.8 KiB
TypeScript
191 lines
7.8 KiB
TypeScript
import { describe, test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
|
||
import {
|
||
KORTFORM_WEIGHTS,
|
||
LONG_FORM_WEIGHTS,
|
||
composite,
|
||
band,
|
||
triage,
|
||
requiredDimensions,
|
||
scoreEnvelope,
|
||
} from "../src/score.js";
|
||
|
||
const r1 = (x: number) => Math.round(x * 10) / 10;
|
||
const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);
|
||
|
||
describe("trends scorer (RE-R1 / B2)", () => {
|
||
// SSOT: references/trend-scoring-modes.md — weights, band thresholds, and action
|
||
// strings are pinned here so silent drift in any of them fails loudly.
|
||
describe("pinned weights (SSOT)", () => {
|
||
test("kortform weights match the SSOT and sum to 1.0", () => {
|
||
assert.equal(KORTFORM_WEIGHTS.pillar, 0.3);
|
||
assert.equal(KORTFORM_WEIGHTS.audience, 0.25);
|
||
assert.equal(KORTFORM_WEIGHTS.timing, 0.2);
|
||
assert.equal(KORTFORM_WEIGHTS.angle, 0.15);
|
||
assert.equal(KORTFORM_WEIGHTS.authority, 0.1);
|
||
assert.equal(r1(sum(KORTFORM_WEIGHTS)), 1.0);
|
||
});
|
||
|
||
test("long-form weights match the SSOT and sum to 1.0", () => {
|
||
assert.equal(LONG_FORM_WEIGHTS.pillar, 0.3);
|
||
assert.equal(LONG_FORM_WEIGHTS.depth, 0.25);
|
||
assert.equal(LONG_FORM_WEIGHTS.angle, 0.2);
|
||
assert.equal(LONG_FORM_WEIGHTS.authority, 0.15);
|
||
assert.equal(LONG_FORM_WEIGHTS.currency, 0.1);
|
||
assert.equal(r1(sum(LONG_FORM_WEIGHTS)), 1.0);
|
||
});
|
||
});
|
||
|
||
describe("composite", () => {
|
||
test("all-tens -> exactly 10.0 (proves Sigma weights = 1.0) for both modes", () => {
|
||
const kort = { pillar: 10, audience: 10, timing: 10, angle: 10, authority: 10 };
|
||
const long = { pillar: 10, depth: 10, angle: 10, authority: 10, currency: 10 };
|
||
assert.equal(composite(kort, "kortform"), 10.0);
|
||
assert.equal(composite(long, "long-form"), 10.0);
|
||
});
|
||
|
||
test("asymmetric golden vector {10,8,6,4,2} in dimension order -> 7.0 for both modes", () => {
|
||
// 10*.30 + 8*.25 + 6*.20 + 4*.15 + 2*.10 = 3.0 + 2.0 + 1.2 + 0.6 + 0.2 = 7.0
|
||
const kort = { pillar: 10, audience: 8, timing: 6, angle: 4, authority: 2 };
|
||
const long = { pillar: 10, depth: 8, angle: 6, authority: 4, currency: 2 };
|
||
assert.equal(composite(kort, "kortform"), 7.0);
|
||
assert.equal(composite(long, "long-form"), 7.0);
|
||
});
|
||
|
||
test("a dimension below 1 throws", () => {
|
||
const kort = { pillar: 0, audience: 5, timing: 5, angle: 5, authority: 5 };
|
||
assert.throws(() => composite(kort, "kortform"), /range|1.*10|dimension/i);
|
||
});
|
||
|
||
test("a dimension above 10 throws", () => {
|
||
const kort = { pillar: 11, audience: 5, timing: 5, angle: 5, authority: 5 };
|
||
assert.throws(() => composite(kort, "kortform"));
|
||
});
|
||
|
||
test("a missing dimension throws", () => {
|
||
const kort = { pillar: 5, audience: 5, timing: 5, angle: 5 }; // authority missing
|
||
assert.throws(() => composite(kort as Record<string, number>, "kortform"));
|
||
});
|
||
});
|
||
|
||
describe("band — boundaries + exact SSOT action strings", () => {
|
||
test("8.0 -> Immediate", () => {
|
||
const b = band(8.0);
|
||
assert.equal(b.priority, "Immediate");
|
||
assert.equal(b.kortformAction, "Draft within 24h");
|
||
assert.equal(b.longformAction, "Promote to the edition backlog now");
|
||
});
|
||
|
||
test("6.0 -> High", () => {
|
||
const b = band(6.0);
|
||
assert.equal(b.priority, "High");
|
||
assert.equal(b.kortformAction, "Publish within 48–72h");
|
||
assert.equal(b.longformAction, "Strong edition candidate — schedule it");
|
||
});
|
||
|
||
test("4.0 -> Medium", () => {
|
||
const b = band(4.0);
|
||
assert.equal(b.priority, "Medium");
|
||
assert.equal(b.kortformAction, "Add to this week's calendar");
|
||
assert.equal(b.longformAction, "Hold as a backlog candidate, revisit");
|
||
});
|
||
|
||
test("2.0 -> Low", () => {
|
||
const b = band(2.0);
|
||
assert.equal(b.priority, "Low");
|
||
assert.equal(b.kortformAction, "Note, skip for now");
|
||
assert.equal(b.longformAction, "Park unless the angle sharpens");
|
||
});
|
||
|
||
test("below 2.0 -> Skip", () => {
|
||
const b = band(1.9);
|
||
assert.equal(b.priority, "Skip");
|
||
assert.equal(b.kortformAction, "Off positioning");
|
||
assert.equal(b.longformAction, "Off positioning");
|
||
});
|
||
|
||
test("just below a boundary lands in the lower band (7.9->High, 5.9->Medium, 3.9->Low)", () => {
|
||
assert.equal(band(7.9).priority, "High");
|
||
assert.equal(band(5.9).priority, "Medium");
|
||
assert.equal(band(3.9).priority, "Low");
|
||
});
|
||
});
|
||
|
||
describe("triage", () => {
|
||
const candidates = [
|
||
{ id: "low", scores: { pillar: 2, audience: 2, timing: 2, angle: 2, authority: 2 } }, // 2.0
|
||
{ id: "high", scores: { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 } }, // 8.0
|
||
{ id: "mid", scores: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 } }, // 5.0
|
||
{ id: "below", scores: { pillar: 3, audience: 3, timing: 3, angle: 3, authority: 3 } }, // 3.0
|
||
];
|
||
|
||
test("keeps composite >= threshold, drops below, ranks kept composite-desc, annotates", () => {
|
||
const { kept, dropped } = triage(candidates, { mode: "kortform", threshold: 4.0 });
|
||
assert.deepEqual(
|
||
kept.map((k) => k.id),
|
||
["high", "mid"],
|
||
); // 8.0, 5.0 desc; both >= 4.0
|
||
assert.deepEqual(
|
||
dropped.map((d) => d.id).sort(),
|
||
["below", "low"],
|
||
); // 3.0, 2.0 < 4.0
|
||
assert.equal(kept[0].composite, 8.0);
|
||
assert.equal(kept[0].band.priority, "Immediate");
|
||
assert.equal(kept[1].composite, 5.0);
|
||
assert.equal(kept[1].band.priority, "Medium");
|
||
});
|
||
|
||
test("threshold is inclusive (composite == threshold is kept)", () => {
|
||
const { kept } = triage(candidates, { mode: "kortform", threshold: 5.0 });
|
||
assert.deepEqual(
|
||
kept.map((k) => k.id),
|
||
["high", "mid"],
|
||
); // mid == 5.0 kept
|
||
});
|
||
|
||
test("an empty candidate list -> empty kept/dropped", () => {
|
||
const { kept, dropped } = triage([], { mode: "kortform", threshold: 4.0 });
|
||
assert.deepEqual(kept, []);
|
||
assert.deepEqual(dropped, []);
|
||
});
|
||
});
|
||
|
||
describe("requiredDimensions (RE-R3a / SC1)", () => {
|
||
test("kortform -> the five keys in SSOT weight order", () => {
|
||
assert.deepEqual(requiredDimensions("kortform"), ["pillar", "audience", "timing", "angle", "authority"]);
|
||
});
|
||
|
||
test("long-form -> the five keys in SSOT weight order", () => {
|
||
assert.deepEqual(requiredDimensions("long-form"), ["pillar", "depth", "angle", "authority", "currency"]);
|
||
});
|
||
|
||
test("order is pinned to the SSOT weight literals (a silent reorder fails)", () => {
|
||
assert.deepEqual(requiredDimensions("kortform"), Object.keys(KORTFORM_WEIGHTS));
|
||
assert.deepEqual(requiredDimensions("long-form"), Object.keys(LONG_FORM_WEIGHTS));
|
||
});
|
||
});
|
||
|
||
describe("scoreEnvelope (RE-R3a / SC1)", () => {
|
||
test("composes composite()+band() — composite/priority equal the existing functions (one owner)", () => {
|
||
const dims = { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 };
|
||
const env = scoreEnvelope("kortform", dims);
|
||
assert.equal(env.mode, "kortform");
|
||
assert.deepEqual(env.dimensions, dims);
|
||
assert.equal(env.composite, composite(dims, "kortform"));
|
||
assert.equal(env.priority, band(composite(dims, "kortform")).priority);
|
||
});
|
||
|
||
test("long-form envelope composes the long-form composite/band", () => {
|
||
const dims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
|
||
const env = scoreEnvelope("long-form", dims);
|
||
assert.equal(env.composite, composite(dims, "long-form"));
|
||
assert.equal(env.priority, band(composite(dims, "long-form")).priority);
|
||
});
|
||
|
||
test("a bad dimension makes scoreEnvelope throw (via composite — defense-in-depth contract)", () => {
|
||
const dims = { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 };
|
||
assert.throws(() => scoreEnvelope("kortform", dims));
|
||
});
|
||
});
|
||
});
|