Honor Scattermind lane hints in rank feedback
This commit is contained in:
@@ -51,6 +51,7 @@ try {
|
||||
{ id: 'workspace', title: 'Accounts and saved workspaces', description: 'Full dashboard with auth, workspace collaboration, team voting, and sync.', dependencies: ['auth', 'teams', 'saved projects', 'sync'], risk: 'Turns the bridge into generic dashboard swamp before the first proof.' },
|
||||
{ id: 'billing', title: 'Subscription billing layer', description: 'Pricing, checkout, invoices, account plans, and admin controls.', dependencies: ['account model', 'checkout', 'fulfillment'], risk: 'Payment machinery before the continuation value is proven.' },
|
||||
{ id: 'export', title: 'Exportable decision brief', description: 'Simple brief for sharing the defended build order.', evidenceNeeded: 'Does a plain brief help a user act within 48 hours?', recommendedLane: 'validate-next' },
|
||||
{ id: 'parked-bridge-dashboard', title: 'Saved Snapshot dashboard with provenance', description: 'Looks bridge-adjacent, but Scattermind already marked it as not-now because it needs auth, saved workspaces, and collaboration first.', recommendedLane: 'park', sourceSection: 'concept-map.parkingLot' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -61,14 +62,19 @@ try {
|
||||
assert.equal(data.input.provenance.artifactId, 'snapshot_123');
|
||||
assert.equal(data.input.provenance.source, 'Scattermind');
|
||||
assert.match(data.input.provenance.originalPrompt, /tiny shop idea/);
|
||||
assert.equal(data.ranked.length, 5);
|
||||
assert.equal(data.ranked.length, 6);
|
||||
assert.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
|
||||
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]);
|
||||
assert.notEqual(data.ranked[0].id, 'workspace', 'dashboard swamp must not win the bridge fixture');
|
||||
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'workspace').lane.id));
|
||||
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').lane.id));
|
||||
assert.equal(data.ranked.find(item => item.id === 'parked-bridge-dashboard').lane.id, 'park', 'explicit Scattermind park hints must stay out of the active proof slice');
|
||||
assert.equal(data.ranked.find(item => item.id === 'parked-bridge-dashboard').lane.source, 'hint');
|
||||
assert.equal(data.ranked.find(item => item.id === 'bridge-contract').provenance.sourceSection, 'concept-map.nextMoves');
|
||||
assert.match(data.ranked.find(item => item.id === 'bridge-contract').factors.evidenceNeeded, /Concept Map/);
|
||||
assert.equal(data.brief.source.artifactId, 'snapshot_123');
|
||||
assert.match(data.brief.summary, /Source: Tiny shop idea clarity pass · snapshot_123/);
|
||||
assert.ok(data.brief.next48Hours.some(item => /Open the source artifact \(snapshot_123\)/i.test(item)));
|
||||
assert.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item)));
|
||||
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i);
|
||||
console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
|
||||
|
||||
@@ -428,7 +428,9 @@ function scoreOption(option, mode, context = '') {
|
||||
const swampHits = hits(text, ['dashboard', 'workspace', 'workspaces', 'auth', 'accounts', 'billing', 'subscription', 'team voting', 'collaboration', 'admin']);
|
||||
const dependencyPenalty = Math.min(2.2, (factors.dependencies || []).length * 0.45);
|
||||
const laneHint = factors.recommendedLane || '';
|
||||
const normalizedLaneHint = normalizeLaneHint(laneHint);
|
||||
const laneBoost = /do|first|now|build/.test(laneHint) ? 0.55 : /validate|test|proof/.test(laneHint) ? 0.25 : /defer|park|cut/.test(laneHint) ? -0.75 : 0;
|
||||
const lanePenalty = normalizedLaneHint === 'park' ? 18 : normalizedLaneHint === 'defer' ? 9 : 0;
|
||||
const metrics = {
|
||||
value: Math.min(10, 4.8 + hits(text, wordSets.value) * 0.9 + coreLoopHits * 0.8 + bridgeHits * 0.75 + proofHits * 0.2 + hits(context, wordSets.value) * 0.15),
|
||||
feasibility: Math.max(1, Math.min(10, 8.2 - effortHits * 0.8 - riskHits * 0.35 - dependencyPenalty + Math.min(1.1, coreLoopHits * 0.28 + bridgeHits * 0.18 + proofHits * 0.12))),
|
||||
@@ -441,12 +443,29 @@ function scoreOption(option, mode, context = '') {
|
||||
const weights = mode.weights;
|
||||
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0);
|
||||
const possible = Object.entries(weights).reduce((sum, [_key, weight]) => sum + Math.abs(weight) * 10, 0);
|
||||
const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100)));
|
||||
const score = Math.max(0, Math.min(100, Math.round(((weighted + laneBoost) + Math.abs(Math.min(0, weights.risk || 0)) * 10) / possible * 100) - lanePenalty));
|
||||
return { ...metrics, score };
|
||||
}
|
||||
|
||||
function normalizeLaneHint(value = '') {
|
||||
const hint = cleanText(value, 40).toLowerCase().replace(/_/g, '-');
|
||||
if (/^(do|do-first|first|build|build-now|now)$/.test(hint)) return 'do';
|
||||
if (/^(validate|validate-next|test|test-next|proof|evidence)$/.test(hint)) return 'test';
|
||||
if (/^(defer|later|sequence-later|after-proof)$/.test(hint)) return 'defer';
|
||||
if (/^(park|cut|drop|icebox|not-now)$/.test(hint)) return 'park';
|
||||
return '';
|
||||
}
|
||||
|
||||
function laneFor(option, rankIndex, total) {
|
||||
const hintedLane = normalizeLaneHint(option.factors?.recommendedLane || '');
|
||||
// Ranker should defend build order, not blindly obey Scattermind. Positive
|
||||
// hints can nudge scoring, but explicit negative hints are safety rails:
|
||||
// if the source already marked something as defer/park, never promote it
|
||||
// into the active proof slice just because keyword scoring liked it.
|
||||
if (hintedLane === 'park') return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan', source: 'hint' };
|
||||
if (hintedLane === 'defer') return { id: 'defer', label: 'Defer', action: 'Sequence after proof', source: 'hint' };
|
||||
if (rankIndex === 0) return { id: 'do', label: 'Do first', action: 'Build/test now' };
|
||||
if (hintedLane === 'test') return { id: 'test', label: 'Validate next', action: 'Find evidence', source: 'hint' };
|
||||
if (rankIndex < Math.max(2, Math.ceil(total * 0.32))) return { id: 'test', label: 'Validate next', action: 'Find evidence' };
|
||||
if (rankIndex >= Math.max(2, Math.floor(total * 0.72))) return { id: 'park', label: 'Park / cut', action: 'Keep out of the active plan' };
|
||||
return { id: 'defer', label: 'Defer', action: 'Sequence after proof' };
|
||||
@@ -473,15 +492,23 @@ function concernFor(option) {
|
||||
return 'The main risk is sequencing: do it only if it supports the first useful proof.';
|
||||
}
|
||||
|
||||
function createDecisionBrief({ idea, context, mode, ranked }) {
|
||||
function createDecisionBrief({ idea, context, mode, ranked, provenance }) {
|
||||
const top = ranked[0];
|
||||
const second = ranked[1];
|
||||
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0];
|
||||
const deferred = ranked.filter(item => ['defer', 'park'].includes(item.lane.id)).slice(0, 3);
|
||||
const sourceLabel = [provenance?.snapshotTitle, provenance?.artifactId].filter(Boolean).join(' · ');
|
||||
const theme = top ? `The strongest signal is “${top.title}” because it has ${reasonFor(top)}.` : 'The list needs at least two options before ranking becomes useful.';
|
||||
return {
|
||||
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map',
|
||||
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}`,
|
||||
summary: `${theme}${second ? ` “${second.title}” is the nearest follow-up, not a parallel first step.` : ''}${sourceLabel ? ` Source: ${sourceLabel}.` : ''}`,
|
||||
source: provenance ? {
|
||||
schema: provenance.schema,
|
||||
source: provenance.source,
|
||||
artifactId: provenance.artifactId,
|
||||
snapshotTitle: provenance.snapshotTitle,
|
||||
conceptMapId: provenance.conceptMapId,
|
||||
} : null,
|
||||
expertReflections: [
|
||||
{
|
||||
lens: 'Product expert',
|
||||
@@ -497,7 +524,7 @@ function createDecisionBrief({ idea, context, mode, ranked }) {
|
||||
},
|
||||
],
|
||||
next48Hours: top ? [
|
||||
`Write a one-paragraph test for “${top.title}”.`,
|
||||
provenance?.artifactId ? `Open the source artifact (${provenance.artifactId}) and mark “${top.title}” as the defended first move.` : `Write a one-paragraph test for “${top.title}”.`,
|
||||
top.factors?.evidenceNeeded ? `Evidence to collect: ${top.factors.evidenceNeeded}.` : 'Name the evidence that would make this decision obviously right or wrong.',
|
||||
'Put it in front of 3–5 real people or run it manually once.',
|
||||
`Do not touch ${deferred[0] ? `“${deferred[0].title}”` : 'the parked ideas'} until the first signal is real.`,
|
||||
@@ -531,7 +558,7 @@ app.post('/api/rank-feedback', (req, res) => {
|
||||
mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
|
||||
input: { idea, context, optionCount: options.length, provenance },
|
||||
ranked: options,
|
||||
brief: createDecisionBrief({ idea, context, mode, ranked: options }),
|
||||
brief: createDecisionBrief({ idea, context, mode, ranked: options, provenance }),
|
||||
buildOrder: {
|
||||
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
|
||||
validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id),
|
||||
|
||||
Reference in New Issue
Block a user