instar 1.3.13 → 1.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * cluster.ts — TS port of the feedback-factory clustering driver.
3
+ *
4
+ * Byte-exact port of the decision loop in `cmd_cluster` (:1405) of the reference
5
+ * `the-portal/.claude/scripts/feedback-processor.py`. Given the unprocessed items
6
+ * and the active (status != 'resolved') clusters, decide for each item whether it
7
+ * MERGES into an existing cluster or CREATES a new one, using Jaccard similarity
8
+ * (similarity.ts, already parity-verified) over `"{title} {description}"`.
9
+ *
10
+ * This is the orchestration on top of the parity'd primitives. It is PURE: the
11
+ * reference reads items+clusters from the DB, then runs this loop with no further
12
+ * I/O (the DB writes happen separately in cmd_apply_clusters). So this ports as a
13
+ * pure function and is parity-tested by monkeypatching the reference's DB query to
14
+ * feed fixtures and running the REAL cmd_cluster loop.
15
+ *
16
+ * ORDER-DEPENDENT within a batch (by design, matching the reference + the spec):
17
+ * a cluster created for an earlier item becomes a match candidate for later items.
18
+ */
19
+ import type { FeedbackItem, Cluster, ClusterResult } from './types.js';
20
+ /**
21
+ * Reproduce Python's `round(x, 3)` — round-half-to-even (banker's rounding) on the
22
+ * float, to 3 decimals. JS `Math.round` is round-half-up, so a naive port diverges
23
+ * on exact-half 4th decimals; this matches the reference. Verified by the parity
24
+ * harness (which seeds an exact-half case).
25
+ */
26
+ export declare function pyRound3(x: number): number;
27
+ /**
28
+ * Port of cmd_cluster's decision loop. Mutates a working copy of `clusters` by
29
+ * appending newly-created clusters (so later items can match them), exactly as the
30
+ * reference does. Returns one ClusterResult per item.
31
+ */
32
+ export declare function clusterItems(items: FeedbackItem[], clusters: Cluster[]): ClusterResult[];
33
+ //# sourceMappingURL=cluster.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster.d.ts","sourceRoot":"","sources":["../../../src/feedback-factory/processor/cluster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAMvE;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAS1C;AAWD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,aAAa,EAAE,CAkExF"}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * cluster.ts — TS port of the feedback-factory clustering driver.
3
+ *
4
+ * Byte-exact port of the decision loop in `cmd_cluster` (:1405) of the reference
5
+ * `the-portal/.claude/scripts/feedback-processor.py`. Given the unprocessed items
6
+ * and the active (status != 'resolved') clusters, decide for each item whether it
7
+ * MERGES into an existing cluster or CREATES a new one, using Jaccard similarity
8
+ * (similarity.ts, already parity-verified) over `"{title} {description}"`.
9
+ *
10
+ * This is the orchestration on top of the parity'd primitives. It is PURE: the
11
+ * reference reads items+clusters from the DB, then runs this loop with no further
12
+ * I/O (the DB writes happen separately in cmd_apply_clusters). So this ports as a
13
+ * pure function and is parity-tested by monkeypatching the reference's DB query to
14
+ * feed fixtures and running the REAL cmd_cluster loop.
15
+ *
16
+ * ORDER-DEPENDENT within a batch (by design, matching the reference + the spec):
17
+ * a cluster created for an earlier item becomes a match candidate for later items.
18
+ */
19
+ import { jaccardSimilarity } from './similarity.js';
20
+ const SIMILARITY_THRESHOLD = 0.35;
21
+ const FIXED_CLUSTER_THRESHOLD = 0.55;
22
+ const FIXED_STATUSES = new Set(['fixed', 'resolved', 'fix_applied']);
23
+ /**
24
+ * Reproduce Python's `round(x, 3)` — round-half-to-even (banker's rounding) on the
25
+ * float, to 3 decimals. JS `Math.round` is round-half-up, so a naive port diverges
26
+ * on exact-half 4th decimals; this matches the reference. Verified by the parity
27
+ * harness (which seeds an exact-half case).
28
+ */
29
+ export function pyRound3(x) {
30
+ const scaled = x * 1000;
31
+ const floor = Math.floor(scaled);
32
+ const diff = scaled - floor;
33
+ let r;
34
+ if (diff > 0.5)
35
+ r = floor + 1;
36
+ else if (diff < 0.5)
37
+ r = floor;
38
+ else
39
+ r = floor % 2 === 0 ? floor : floor + 1; // half → nearest even
40
+ return r / 1000;
41
+ }
42
+ /** Port of cmd_cluster's slug: `re.sub(r'[^a-z0-9]+','-', title.lower())[:60].strip('-')`. */
43
+ function slugify(title) {
44
+ return title
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9]+/g, '-')
47
+ .slice(0, 60)
48
+ .replace(/^-+|-+$/g, '');
49
+ }
50
+ /**
51
+ * Port of cmd_cluster's decision loop. Mutates a working copy of `clusters` by
52
+ * appending newly-created clusters (so later items can match them), exactly as the
53
+ * reference does. Returns one ClusterResult per item.
54
+ */
55
+ export function clusterItems(items, clusters) {
56
+ // Work on a shallow copy so the caller's array isn't mutated, while still
57
+ // letting created clusters become candidates for subsequent items in the batch.
58
+ const working = [...clusters];
59
+ const results = [];
60
+ for (const item of items) {
61
+ const itemText = `${item.title} ${item.description}`;
62
+ let bestMatch = null;
63
+ let bestScore = 0.0;
64
+ for (const cluster of working) {
65
+ const clusterText = `${cluster.title} ${cluster.description}`;
66
+ const score = jaccardSimilarity(itemText, clusterText);
67
+ if (score > bestScore) { // strict — first cluster reaching the max wins (tie keeps earlier)
68
+ bestScore = score;
69
+ bestMatch = cluster;
70
+ }
71
+ }
72
+ let threshold = SIMILARITY_THRESHOLD;
73
+ if (bestMatch && FIXED_STATUSES.has(bestMatch.status ?? '')) {
74
+ threshold = FIXED_CLUSTER_THRESHOLD;
75
+ if (bestScore >= SIMILARITY_THRESHOLD && bestScore < FIXED_CLUSTER_THRESHOLD) {
76
+ // FALSE-MERGE-GUARD near-miss — reference logs to stdout for observability.
77
+ // The pure port returns the decision (create); the log line is a side effect
78
+ // the caller/observability layer emits, so it is intentionally not produced here.
79
+ }
80
+ }
81
+ if (bestMatch && bestScore >= threshold) {
82
+ let mergeNote = '';
83
+ const st = bestMatch.status ?? '';
84
+ if (FIXED_STATUSES.has(st)) {
85
+ mergeNote = ' (merged into fixed cluster — possible regression)';
86
+ }
87
+ else if (st === 'deferred') {
88
+ mergeNote = ' (merged into deferred singleton — possible regression, reopening)';
89
+ }
90
+ const result = {
91
+ feedbackId: item.feedbackId,
92
+ action: 'merge',
93
+ clusterId: bestMatch.clusterId,
94
+ clusterTitle: bestMatch.title,
95
+ similarity: pyRound3(bestScore),
96
+ };
97
+ if (mergeNote)
98
+ result.note = mergeNote;
99
+ results.push(result);
100
+ }
101
+ else {
102
+ const clusterId = `cluster-${slugify(item.title)}`;
103
+ const newCluster = {
104
+ clusterId,
105
+ title: item.title,
106
+ description: item.description,
107
+ type: item.type,
108
+ };
109
+ working.push(newCluster); // visible to later items in the batch
110
+ results.push({
111
+ feedbackId: item.feedbackId,
112
+ action: 'create',
113
+ clusterId,
114
+ similarity: bestMatch ? pyRound3(bestScore) : 0.0,
115
+ });
116
+ }
117
+ }
118
+ return results;
119
+ }
120
+ //# sourceMappingURL=cluster.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster.js","sourceRoot":"","sources":["../../../src/feedback-factory/processor/cluster.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * types.ts — shared data shapes for the feedback-factory processor.
3
+ *
4
+ * Mirror the reference (the-portal Prisma models InstarFeedback / InstarFeedbackCluster
5
+ * + the JSON the processor passes around). Only the fields the ported pure logic
6
+ * actually reads are required; the rest are optional so a real store adapter can
7
+ * pass full rows without the pure functions caring.
8
+ */
9
+ /** A raw feedback report (subset of InstarFeedback used by the pure processor logic). */
10
+ export interface FeedbackItem {
11
+ feedbackId: string;
12
+ title: string;
13
+ description: string;
14
+ type: string;
15
+ status?: string;
16
+ receivedAt?: string;
17
+ instarVersion?: string;
18
+ [k: string]: unknown;
19
+ }
20
+ /** A dedup cluster (subset of InstarFeedbackCluster used by the pure processor logic). */
21
+ export interface Cluster {
22
+ clusterId: string;
23
+ title: string;
24
+ description: string;
25
+ type?: string;
26
+ status?: string;
27
+ fingerprint?: string;
28
+ recurrenceCount?: number;
29
+ reportCount?: number;
30
+ createdAt?: string;
31
+ updatedAt?: string;
32
+ fixedInVersion?: string;
33
+ fixAppliedAt?: string;
34
+ dispatchedAt?: string;
35
+ [k: string]: unknown;
36
+ }
37
+ /** One clustering decision for a feedback item (mirrors cmd_cluster's result dicts). */
38
+ export interface ClusterResult {
39
+ feedbackId: string;
40
+ action: 'merge' | 'create';
41
+ clusterId: string;
42
+ similarity: number;
43
+ clusterTitle?: string;
44
+ note?: string;
45
+ }
46
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/feedback-factory/processor/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,yFAAyF;AACzF,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED,0FAA0F;AAC1F,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED,wFAAwF;AACxF,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * types.ts — shared data shapes for the feedback-factory processor.
3
+ *
4
+ * Mirror the reference (the-portal Prisma models InstarFeedback / InstarFeedbackCluster
5
+ * + the JSON the processor passes around). Only the fields the ported pure logic
6
+ * actually reads are required; the rest are optional so a real store adapter can
7
+ * pass full rows without the pure functions caring.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/feedback-factory/processor/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.13",
3
+ "version": "1.3.14",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+ """Parity reference: run the REAL cmd_cluster decision loop over a fixture.
3
+
4
+ Imports the reference processor without running its CLI main, then MONKEYPATCHES
5
+ its `run_prisma_query` so the two DB reads cmd_cluster does (unprocessed items,
6
+ then active clusters) return our fixture arrays instead of hitting a database.
7
+ cmd_cluster then runs its actual, unmodified decision loop and RETURNS the
8
+ results — which is exactly what the TS port must reproduce. cmd_cluster's own
9
+ stdout (FALSE-MERGE-GUARD logs + its json.dumps) is suppressed; we emit the
10
+ captured return value as clean JSON.
11
+
12
+ Usage: python3 _py_clustering_ref.py <processor_path> <fixture_path>
13
+ """
14
+ import sys
15
+ import io
16
+ import json
17
+ import importlib.util
18
+ import contextlib
19
+
20
+
21
+ def load_processor(path):
22
+ spec = importlib.util.spec_from_file_location("_feedback_processor_ref_cl", path)
23
+ mod = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(mod)
25
+ return mod
26
+
27
+
28
+ def main():
29
+ if len(sys.argv) != 3:
30
+ print("usage: _py_clustering_ref.py <processor_path> <fixture_path>", file=sys.stderr)
31
+ sys.exit(2)
32
+ processor_path, fixture_path = sys.argv[1], sys.argv[2]
33
+ proc = load_processor(processor_path)
34
+ with open(fixture_path, encoding="utf-8") as f:
35
+ fixture = json.load(f)
36
+
37
+ # cmd_cluster calls run_prisma_query twice in order: items first, clusters second.
38
+ responses = [json.dumps(fixture["items"]), json.dumps(fixture["clusters"])]
39
+ call = {"n": 0}
40
+
41
+ def fake_query(*_args, **_kwargs):
42
+ i = call["n"]
43
+ call["n"] += 1
44
+ return responses[i] if i < len(responses) else "[]"
45
+
46
+ proc.run_prisma_query = fake_query
47
+
48
+ # Run the REAL loop; suppress cmd_cluster's stdout noise, capture its return.
49
+ with contextlib.redirect_stdout(io.StringIO()):
50
+ results = proc.cmd_cluster()
51
+
52
+ sys.stdout.write(json.dumps(results if results is not None else [], ensure_ascii=False))
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,17 @@
1
+ {
2
+ "_comment": "Parity fixture for the clustering driver. `clusters` = pre-existing active clusters; `items` = unprocessed feedback processed in order. Exercises: create-new, merge into open cluster, the 0.55 fixed-cluster false-merge guard (score in [0.35,0.55) vs a fixed cluster → create), merge into fixed (regression note), merge into deferred (reopen note), order-dependence (a later item matching a cluster created for an earlier item), and slug generation. The Python ref runs the REAL cmd_cluster with its DB query monkeypatched to these arrays.",
3
+ "clusters": [
4
+ { "clusterId": "cluster-existing-open", "title": "GitSync pull fails intermittently", "description": "the pull step times out under load", "type": "bug", "status": "investigating" },
5
+ { "clusterId": "cluster-existing-fixed", "title": "auth token refresh broken", "description": "token refresh returns 401 after expiry", "type": "bug", "status": "fixed" },
6
+ { "clusterId": "cluster-existing-deferred", "title": "dashboard pin rotation edge case", "description": "pin rotation skips on concurrent login", "type": "bug", "status": "deferred" }
7
+ ],
8
+ "items": [
9
+ { "feedbackId": "fb-1", "title": "GitSync pull fails intermittently", "description": "the pull step times out under load", "type": "bug" },
10
+ { "feedbackId": "fb-2", "title": "completely different telemetry export bug", "description": "csv export drops the last row", "type": "bug" },
11
+ { "feedbackId": "fb-3", "title": "telemetry export bug csv", "description": "the export drops the final row of data", "type": "bug" },
12
+ { "feedbackId": "fb-4", "title": "auth token refresh broken", "description": "token refresh returns 401 after expiry", "type": "bug" },
13
+ { "feedbackId": "fb-5", "title": "auth token refresh sometimes", "description": "occasional 401 seen again", "type": "bug" },
14
+ { "feedbackId": "fb-6", "title": "dashboard pin rotation edge case", "description": "pin rotation skips on concurrent login", "type": "bug" },
15
+ { "feedbackId": "fb-7", "title": "Brand New Issue: Weird Unicode café title", "description": "something about an emoji in the report", "type": "feature" }
16
+ ]
17
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * clustering-parity.mjs — LOCAL parity gate for the clustering driver port.
4
+ *
5
+ * Runs the REAL reference cmd_cluster (DB monkeypatched to a fixture) and the TS
6
+ * clusterItems() over the same fixture, and asserts the per-item decisions match
7
+ * exactly: action (merge/create), clusterId, rounded similarity, and the merge
8
+ * note. LOCAL evidence gate, not CI (CI lacks the reference checkout).
9
+ *
10
+ * Exit 0 = parity; exit 1 = mismatch.
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const ROOT = path.resolve(__dirname, '..', '..');
20
+
21
+ const PROCESSOR = process.env.PORTAL_PROCESSOR
22
+ || '/Users/justin/Documents/Projects/the-portal/.claude/scripts/feedback-processor.py';
23
+ const FIXTURE = path.join(__dirname, 'clustering-corpus.json');
24
+ const PY_REF = path.join(__dirname, '_py_clustering_ref.py');
25
+
26
+ if (!fs.existsSync(PROCESSOR)) {
27
+ console.error(`[cluster-parity] reference processor not found: ${PROCESSOR}`);
28
+ process.exit(2);
29
+ }
30
+
31
+ const py = spawnSync('python3', [PY_REF, PROCESSOR, FIXTURE], { encoding: 'utf8' });
32
+ if (py.status !== 0) {
33
+ console.error('[cluster-parity] python reference failed:\n', py.stderr || py.stdout);
34
+ process.exit(2);
35
+ }
36
+ const reference = JSON.parse(py.stdout);
37
+
38
+ const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8'));
39
+ const distUrl = new URL('file://' + path.join(ROOT, 'dist', 'feedback-factory', 'processor', 'cluster.js'));
40
+ const { clusterItems } = await import(distUrl.href);
41
+ const got = clusterItems(fixture.items, fixture.clusters);
42
+
43
+ let mismatches = 0;
44
+ const norm = (r) => JSON.stringify({
45
+ feedbackId: r.feedbackId, action: r.action, clusterId: r.clusterId,
46
+ similarity: r.similarity, note: r.note ?? null,
47
+ });
48
+
49
+ if (got.length !== reference.length) {
50
+ console.error(`[cluster-parity] length mismatch: ts=${got.length} python=${reference.length}`);
51
+ mismatches++;
52
+ }
53
+ for (let i = 0; i < Math.max(got.length, reference.length); i++) {
54
+ const a = got[i] ? norm(got[i]) : '(missing)';
55
+ const b = reference[i] ? norm(reference[i]) : '(missing)';
56
+ if (a !== b) {
57
+ mismatches++;
58
+ console.error(`[MISMATCH #${i}]`);
59
+ console.error(` python: ${b}`);
60
+ console.error(` ts : ${a}`);
61
+ }
62
+ }
63
+
64
+ const total = reference.length;
65
+ if (mismatches === 0) {
66
+ console.error(`[cluster-parity] ✓ ${total}/${total} clustering decisions match the reference cmd_cluster.`);
67
+ process.exit(0);
68
+ } else {
69
+ console.error(`[cluster-parity] ✗ ${mismatches} mismatch(es) — the TS port diverges from the reference.`);
70
+ process.exit(1);
71
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-05-27T06:03:48.163Z",
5
- "instarVersion": "1.3.13",
4
+ "generatedAt": "2026-05-27T06:14:27.030Z",
5
+ "instarVersion": "1.3.14",
6
6
  "entryCount": 192,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,26 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: patch -->
4
+
5
+ ## What Changed
6
+
7
+ Fourth increment of the **Feedback Factory Migration** (Dawn → Echo; spec `docs/specs/feedback-factory-migration.md`, approved). Ports the **clustering driver** — the decision loop of `cmd_cluster` — from the reference Python (`the-portal/.claude/scripts/feedback-processor.py`) to TypeScript at `src/feedback-factory/processor/cluster.ts` (`clusterItems`), plus shared types in `src/feedback-factory/processor/types.ts`.
8
+
9
+ This is the orchestration that ties the brain together: for each incoming report it picks the most-similar existing cluster (via the already-ported Jaccard matcher) and either merges into it or creates a new one — applying the higher 0.55 similarity bar before merging into an already-fixed cluster (the false-merge guard), and flagging possible regressions/reopens. Pure functions; **not wired into any route or job yet** — no behavioral change.
10
+
11
+ ## What to Tell Your User
12
+
13
+ - The part of the feedback brain that actually groups reports into "this is one bug" piles is now ported — including the safety rule that makes it harder to wrongly lump a new report onto an already-fixed bug.
14
+ - Proven against Dawn's original the strongest way yet: I fed the same reports through her real grouping code and my rewrite and got identical grouping decisions, 7 for 7.
15
+
16
+ ## Summary of New Capabilities
17
+
18
+ | Capability | How to Use |
19
+ |-----------|-----------|
20
+ | Clustering driver (TS port) | Internal module `src/feedback-factory/processor/cluster.ts` — not yet wired |
21
+ | Clustering parity harness | `node scripts/feedback-factory/clustering-parity.mjs` (local; set `PORTAL_PROCESSOR`) |
22
+
23
+ ## Evidence
24
+
25
+ - **Parity vs the REAL `cmd_cluster`:** the harness monkeypatches the reference's database query to feed a fixture (existing clusters + incoming items) and runs cmd_cluster's actual, unmodified decision loop, capturing its results — then compares to the TS port. Result: **7/7 identical** decisions (merge vs create, which cluster, rounded similarity, and the regression/reopen note). The fixture exercises create, merge-into-open, the 0.55 false-merge guard, merge-into-fixed (regression), merge-into-deferred (reopen), and order-dependence (a later report matching a cluster created moments earlier in the same batch).
26
+ - **CI anchors:** unit tests assert each of those behaviors plus the Python-style half-to-even rounding, so a regression fails in CI even without the reference checkout.
@@ -0,0 +1,34 @@
1
+ # Side-Effects Review — feedback-factory clustering driver (Phase 1, increment 4)
2
+
3
+ **Slug:** `feedback-factory-clustering`
4
+ **Date:** `2026-05-27`
5
+ **Author:** Echo (autonomous)
6
+ **Spec:** `docs/specs/feedback-factory-migration.md` (converged v2, approved by Justin 2026-05-26)
7
+ **Scope:** The clustering decision loop of `cmd_cluster` (:1405) — assign each unprocessed item to an existing cluster or create a new one. Pure orchestration over the already-parity'd Jaccard similarity. The DB read/write halves (the queries + `cmd_apply_clusters`) are the store/adapter layer, a later increment.
8
+
9
+ ## Summary of the change
10
+
11
+ Byte-exact port of `cmd_cluster`'s decision loop from `the-portal/.claude/scripts/feedback-processor.py` to `src/feedback-factory/processor/cluster.ts` as the pure function `clusterItems(items, clusters)`. Adds shared types (`src/feedback-factory/processor/types.ts`: `FeedbackItem`/`Cluster`/`ClusterResult`), the clustering parity harness, and Tier-1 unit tests. Pure functions, no I/O. **Not wired into any route/job yet** — no behavioral change.
12
+
13
+ The decision rules ported: per item, pick the highest-Jaccard cluster over `"{title} {description}"`; merge if score ≥ threshold (0.55 for `fixed`/`resolved`/`fix_applied` clusters — the false-merge guard — else 0.35); else create a new `cluster-<slug>` and make it a candidate for later items in the batch (order-dependent, by design). Regression/reopen merge-notes preserved.
14
+
15
+ ## Equivalence verification
16
+
17
+ - **7/7 fixture decisions match the REAL `cmd_cluster`.** The parity harness monkeypatches the reference's `run_prisma_query` to feed (items, clusters) fixtures and runs cmd_cluster's actual, unmodified loop, capturing its returned results — so this is parity against the real code, not a re-implementation. Matched fields: action, clusterId, rounded similarity, and merge note. Fixture exercises create / merge-open / the 0.55 false-merge guard / merge-into-fixed (regression) / merge-into-deferred (reopen) / order-dependence / slug.
18
+ - Builds on the already-parity'd `jaccardSimilarity` (increment 2, 20/20), so the only new surface is the orchestration + `pyRound3` (Python round-half-to-even, unit-tested on exact-half cases).
19
+
20
+ ## Seven-dimension review
21
+
22
+ 1. **Over/under-reach** — Pure deterministic function, no I/O, no global state, not imported by any runtime path. Works on a shallow COPY of the clusters array so the caller's input isn't mutated (while still letting created clusters match later items, as the reference does).
23
+ 2. **Level-of-abstraction fit** — Processor-logic layer, on top of the similarity primitive. The DB I/O (reads + `cmd_apply_clusters` writes) is deliberately NOT here — it belongs to the store/adapter layer, keeping this pure + parity-testable.
24
+ 3. **Signal vs Authority** — N/A; pure computation producing a decision list. The FALSE-MERGE-GUARD near-miss LOG line is intentionally NOT emitted by the pure function (it's a stdout side effect of the reference); the observability layer emits it. The DECISION (create vs merge) is identical.
25
+ 4. **Interactions** — None. New isolated module; nothing imports it yet. Parity scripts LOCAL-only.
26
+ 5. **Rollback cost** — Trivial: delete the module + tests + scripts. `types.ts` is additive.
27
+ 6. **Migration parity** — N/A. New internal library code; no agent-installed file touched.
28
+ 7. **Failure modes** — (a) Port diverges from reference → parity harness (7/7, against the real loop) + unit tests. (b) `pyRound3` half-rounding divergence → unit-tested on exact-half cases + parity. (c) Order-dependence not reproduced → unit + parity both exercise a later-item-matches-earlier-created-cluster case. (d) The FALSE-MERGE-GUARD stdout log isn't reproduced → intentional (pure function); documented; the observability increment will emit it.
29
+
30
+ ## Tests
31
+
32
+ - Tier-1 unit (CI): `tests/unit/feedback-factory/cluster.test.ts` — pyRound3 (incl. half-to-even), create/merge, the 0.55 guard, regression + reopen notes, order-dependence. 8 tests.
33
+ - Parity (local gate, evidence): `scripts/feedback-factory/clustering-parity.mjs` → **7/7** vs the real `cmd_cluster`.
34
+ - No integration/E2E this increment: not wired to a route/job; those tiers attach when the store/apply layer + job land. Reasoned decision, documented.