Honor Scattermind lane hints in rank feedback

This commit is contained in:
OpenClaw Bot
2026-05-26 22:27:19 +02:00
parent 8b7477e323
commit f4bb937302
2 changed files with 39 additions and 6 deletions
+7 -1
View File
@@ -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: '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: '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: '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.artifactId, 'snapshot_123');
assert.equal(data.input.provenance.source, 'Scattermind'); assert.equal(data.input.provenance.source, 'Scattermind');
assert.match(data.input.provenance.originalPrompt, /tiny shop idea/); 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.deepEqual(Object.keys(data.buildOrder), ['doFirst', 'validateNext', 'defer', 'park']);
assert.equal(data.ranked[0].id, data.buildOrder.doFirst[0]); 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.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 === 'workspace').lane.id));
assert.ok(['defer', 'park'].includes(data.ranked.find(item => item.id === 'billing').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.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.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.ok(data.brief.next48Hours.some(item => /Evidence to collect/i.test(item)));
assert.match(data.brief.summary, /nearest follow-up|strongest signal/i); 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)); console.log(JSON.stringify({ ok: true, top: data.ranked[0].id, provenance: data.input.provenance, buildOrder: data.buildOrder }, null, 2));
+32 -5
View File
@@ -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 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 dependencyPenalty = Math.min(2.2, (factors.dependencies || []).length * 0.45);
const laneHint = factors.recommendedLane || ''; 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 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 = { 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), 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))), 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 weights = mode.weights;
const weighted = Object.entries(weights).reduce((sum, [key, weight]) => sum + metrics[key] * weight, 0); 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 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 }; 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) { 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 (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.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' }; 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' }; 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.'; 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 top = ranked[0];
const second = ranked[1]; const second = ranked[1];
const risky = ranked.slice().sort((a, b) => b.metrics.risk - a.metrics.risk)[0]; 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 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.'; 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 { return {
headline: top ? `Start with ${top.title}` : 'Add options to get a ranked feedback map', 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: [ expertReflections: [
{ {
lens: 'Product expert', lens: 'Product expert',
@@ -497,7 +524,7 @@ function createDecisionBrief({ idea, context, mode, ranked }) {
}, },
], ],
next48Hours: top ? [ 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.', 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 35 real people or run it manually once.', 'Put it in front of 35 real people or run it manually once.',
`Do not touch ${deferred[0] ? `${deferred[0].title}` : 'the parked ideas'} until the first signal is real.`, `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 }, mode: { id: modeId in judgementModes ? modeId : 'progress', label: mode.label },
input: { idea, context, optionCount: options.length, provenance }, input: { idea, context, optionCount: options.length, provenance },
ranked: options, ranked: options,
brief: createDecisionBrief({ idea, context, mode, ranked: options }), brief: createDecisionBrief({ idea, context, mode, ranked: options, provenance }),
buildOrder: { buildOrder: {
doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id), doFirst: options.filter(item => item.lane.id === 'do').map(item => item.id),
validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id), validateNext: options.filter(item => item.lane.id === 'test').map(item => item.id),