From cf9c7be87ff4cc8483835971866983837e2d9335 Mon Sep 17 00:00:00 2001 From: WGeorgezzzz <257936154+WGeorgezzzz@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:07:51 +0800 Subject: [PATCH 1/2] Fix research organization review and focused run lineage --- docs/ARCHITECTURE.md | 23 +++++ research_harness/cli.py | 5 +- research_harness/organization.py | 70 ++++++++++++++ research_harness/server.py | 3 +- .../skill/research-harness/SKILL.md | 14 +++ .../research-harness/references/PROTOCOL.md | 23 +++++ research_harness/static/app.css | 4 + research_harness/static/app.js | 66 ++++++++++--- tests/test_organization.py | 92 +++++++++++++++++++ tests/test_research_tree.cjs | 62 +++++++++++++ 10 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 research_harness/organization.py create mode 100644 tests/test_organization.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 282b1f8..2a4b2e6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -135,6 +135,29 @@ An archived Cell is absent from the current map hierarchy and numeric comparison archived claims and decisions remain in the historical decisions list with their evidence. Archiving does not delete their source snapshots. +### Research organization review and focused lineage + +Recorded Run-to-Run derivations are projected between their owning Cells for +display. The projection preserves source Run edges, merges duplicate visual +links, and omits intra-Cell replication edges. It does not assert common ancestry +for every replicate or alter identities. Missing origins, multiple parents and +projection cycles remain reviewable; no lineage is inferred from labels or dates. + +A project-level module may name a stable `attrs.current_focus` node. The default +canvas then shows that line and its recorded origins in one lane. All research +tracks and Outline retain access to the complete history. Lane labels preserve +the recorded research topic even when every condition is a root. +A Run focus resolves to its owning Cell. Archived focus declarations are ignored; +unsupported focus targets are reported without hiding the overview. Hierarchy +ancestors are retained when they provide the recorded lineage fallback. + +`organization.py` provides read-only structural hints in `context`, `validate` +and presentation data. A large condition list without synthesis or comparison, +wide peer sections and invalid focus references request review. Independent +study designs may legitimately have no lineage. These hints neither invalidate +schema nor certify scientific meaning; the host still authors the research +reasoning separately from implementation flow and the run ledger. + ## Provider boundary The optional `autoresearch.py` workflow and `backends/shinka.py` adapter execute diff --git a/research_harness/cli.py b/research_harness/cli.py index 9661f1f..cbe51b6 100644 --- a/research_harness/cli.py +++ b/research_harness/cli.py @@ -11,6 +11,7 @@ from .store import Store, HarnessError, atomic_write, read_json, json_write, validate_state from .scan import sync from .reasoning import compare, assess, drift +from .organization import organization from .server import export_html, export_site, serve @@ -222,7 +223,7 @@ def main(argv: list[str] | None = None) -> int: top = [n for n in nodes if n["kind"] not in {"run", "evaluation", "artifact"}] emit({"project": state["project"], "base_revision": state["revision"], "nodes": top, "node_count": len(nodes), "file_count": len(state["files"]), "drift": drift(state), - "scan": state["scan"], "history": state["history"][-12:], + "scan": state["scan"], "history": state["history"][-12:], "organization": organization(state), "next": "Use files for paginated evidence, context --node ID, or --full. Read evidence before applying a semantic patch."}) elif args.command == "files": state = store.load() @@ -253,7 +254,7 @@ def main(argv: list[str] | None = None) -> int: except HarnessError as exc: failures.append({"node": n["id"], "error": str(exc)}) emit({"valid": not failures, "revision": state["revision"], "nodes": len(state["nodes"]), - "evidence_errors": failures, "drift": drift(state), + "evidence_errors": failures, "drift": drift(state), "organization": organization(state), "scope": "Mechanical validation only; not validation of scientific truth or host-model reliability."}) return 1 if failures else 0 elif args.command == "export": diff --git a/research_harness/organization.py b/research_harness/organization.py new file mode 100644 index 0000000..6b6b970 --- /dev/null +++ b/research_harness/organization.py @@ -0,0 +1,70 @@ +"""Read-only structural review hints; not a scientific-quality certificate.""" +from __future__ import annotations + + +def organization(state: dict) -> dict: + nodes = {key: n for key, n in state['nodes'].items() if n.get('status') != 'archived'} + warnings = [] + children = {} + for n in nodes.values(): + children.setdefault(n.get('parent_id'), []).append(n) + + def ancestor(node_id, kinds): + seen = set() + while node_id in nodes and node_id not in seen: + seen.add(node_id) + n = nodes[node_id] + if n['kind'] in kinds: + return node_id + node_id = n.get('parent_id') + return None + + studies = {} + for n in nodes.values(): + if n['kind'] == 'cell': + owner = ancestor(n.get('parent_id'), {'module', 'question'}) + studies.setdefault(owner, []).append(n['id']) + related = set() + run_lineage = 0 + for e in state.get('edges', {}).values(): + if e['relation'] not in {'derived_from', 'compares', 'inspired_by'}: + continue + source, target = nodes.get(e['source']), nodes.get(e['target']) + if not source or not target or source['kind'] not in {'cell', 'run'} or target['kind'] not in {'cell', 'run'}: + continue + a, b = ancestor(source['id'], {'cell'}), ancestor(target['id'], {'cell'}) + if a and b and a != b: + related.update((a, b)) + if e['relation'] == 'derived_from' and (source['kind'] == 'run' or target['kind'] == 'run'): + run_lineage += 1 + for owner, cells in studies.items(): + if len(cells) < 5 or nodes.get(owner, {}).get('attrs', {}).get('role') == 'evidence_audit': + continue + attrs = nodes.get(owner, {}).get('attrs', {}) + if not attrs.get('study_summary'): + warnings.append({'code': 'missing_study_synthesis', 'node_id': owner, 'cell_ids': cells, + 'message': 'Several conditions have no study synthesis. Record the question, comparison, outcome and remaining gap on their study.'}) + if not any(c in related for c in cells) and not attrs.get('design'): + warnings.append({'code': 'unexplained_condition_list', 'node_id': owner, 'cell_ids': cells, + 'message': 'Conditions have neither recorded comparisons/lineage nor an explicit independent study design. Review evidence; never invent edges to make a tree.'}) + for parent, items in children.items(): + modules = [n for n in items if n['kind'] in {'module', 'question'} and n.get('attrs', {}).get('role') != 'evidence_audit'] + if len(modules) >= 8: + warnings.append({'code': 'wide_research_outline', 'node_id': parent, + 'related_ids': [n['id'] for n in modules], + 'message': 'Many peer research sections. Review whether they form method families, comparisons, or historical diagnostics; retain independent topics when justified.'}) + focuses = [] + for n in nodes.values(): + focus = n.get('attrs', {}).get('current_focus') + if focus is None: + continue + target = nodes.get(focus) if isinstance(focus, str) else None + if (not target or target['kind'] not in {'module', 'question', 'cell', 'run'} + or (target['kind'] == 'run' and ancestor(focus, {'cell'}) is None)): + warnings.append({'code': 'invalid_current_focus', 'node_id': n['id'], + 'message': 'Current focus must reference a visible module, question, cell, or run with a visible owning cell.'}) + else: + focuses.append(focus) + return {'needs_review': bool(warnings), 'warnings': warnings, + 'run_lineage_edges': run_lineage, 'current_focus_ids': sorted(set(focuses)), + 'scope': 'Structural review hints only. Independent experiments need not form a lineage; schema validity does not certify research organization or scientific conclusions.'} diff --git a/research_harness/server.py b/research_harness/server.py index 383a362..3de4ebb 100644 --- a/research_harness/server.py +++ b/research_harness/server.py @@ -11,13 +11,14 @@ import webbrowser from .store import Store, HarnessError, atomic_write, canonical from .reasoning import compare, drift +from .organization import organization STATIC = Path(__file__).parent / "static" def presentation(store: Store) -> dict: state = store.load() - return {**state, "comparison": compare(state), "drift": drift(state), "mode": "live"} + return {**state, "comparison": compare(state), "drift": drift(state), "organization": organization(state), "mode": "live"} def export_html(store: Store, output: Path, *, include_evidence: bool = False, diff --git a/research_harness/skill/research-harness/SKILL.md b/research_harness/skill/research-harness/SKILL.md index 86d0f9f..218e389 100644 --- a/research_harness/skill/research-harness/SKILL.md +++ b/research_harness/skill/research-harness/SKILL.md @@ -80,6 +80,14 @@ below use legacy `rh` as shorthand for that same CLI. Quote all paths. from sources, the researcher, or agent interpretation. Do not promote a directory hierarchy into this outline. If accounts conflict, retain their version and scope instead of choosing a convenient narrative. + Keep three separate explanations: research reasoning (failure/question -> + hypothesis -> controlled comparison -> result -> decision), the chosen + implementation's execution/data flow, and the full run ledger. A batch list + or runtime phase list is not a research reasoning chain. Explain why each + important result changed the next decision. Reuse the best-supported lineage + before opening another branch. When the user selects a main line, record its + stable ID as `attrs.current_focus` on the project-level research module; + retain parked studies and evidence as history. 5. **Map the experiment design and testable claims.** Use `rh context WORKSPACE --full` or `--node ID` and reuse IDs. Nest modules for project-specific research tracks, studies and ablation groups; connect questions → cells → runs @@ -153,6 +161,12 @@ below use legacy `rh` as shorthand for that same CLI. Quote all paths. human-protected corrections. Review source-drift flags without declaring every old conclusion invalid. 9. **Check and visualize.** Run `rh validate WORKSPACE` and `rh compare WORKSPACE`. + Inspect `organization` in `context` and `validate`; `valid: true` checks + schema/evidence, not whether the research narrative is organized. Resolve or + explain structural review hints before delivery. Inspect the rendered map: + Run-level derivations must remain visible in the Cell overview. Never infer + parentage from version numbers or dates to make a prettier tree. A justified + independent comparison does not need artificial lineage edges. Current comparisons exclude runs whose metric evidence changed or disappeared; their old snapshots remain historical evidence. Address mechanical errors, not by weakening provenance. Export with `rh export diff --git a/research_harness/skill/research-harness/references/PROTOCOL.md b/research_harness/skill/research-harness/references/PROTOCOL.md index fb1d0f3..52a539f 100644 --- a/research_harness/skill/research-harness/references/PROTOCOL.md +++ b/research_harness/skill/research-harness/references/PROTOCOL.md @@ -16,6 +16,29 @@ Relations: `derived_from`, `compares`, `supports`, `contradicts`, `uses_checkpoint`, `supersedes`, `inspired_by`. Never confuse method derivation with inherited weights. Every edge endpoint must exist. +## Research Organization and Display + +The map projects recorded Run-to-Run derivations between their owning Cells. +This is read-only provenance aggregation, not a claim that every replicate has +the same ancestry. Duplicate visual links retain their Run sources; intra-Cell +replication is not a new condition. Multiple origins and cycles remain visible. +Record the actual Run relationship instead of inventing a Cell-level one for +the renderer. + +An optional `attrs.current_focus: "STABLE_NODE_ID"` on a project-level module +selects the current research line. The focused view retains recorded origins; +All research tracks and Outline expose the full history. It does not change +scientific status, evidence, or run counts. +Use a visible module, question or Cell ID; a Run ID resolves to its owning Cell. +Archived declarations and unsupported focus targets do not hide the full map. + +Keep the research reasoning chain separate from implementation execution flow +and the run ledger. Each major step identifies the question, baseline, change, +observed result and ensuing decision. Source names and runtime phases are not +research reasoning. The `organization` report in `context`/`validate` provides +structural review hints; warnings neither invalidate schema nor establish +scientific quality. Independent studies may legitimately have no lineage edges. + ## Per-version change annotations Every version, attempt or candidate shown in a research map needs a readable diff --git a/research_harness/static/app.css b/research_harness/static/app.css index f726aa4..db9d847 100644 --- a/research_harness/static/app.css +++ b/research_harness/static/app.css @@ -304,6 +304,10 @@ button.history-change:hover{background:var(--soft)} @media(max-width:700px){.node-toggle{width:25px;height:25px;min-width:25px}.node-toggle svg{width:15px;height:15px}.study-panel{font-size:12.5px}.study-panel p{font-size:12.5px}.study-table{font-size:11.5px}.study-facts .kv,.study-technical .kv{font-size:11.5px}} /* One local site can offer several separately sourced research workspaces. */ +.tree-scope-control{display:inline-flex;align-items:center;gap:5px;font-size:12px;white-space:nowrap;letter-spacing:0} +.tree-scope-control input{width:16px;height:16px;margin:0;accent-color:#286447} +.research-canvas.is-focused{height:340px;min-height:260px} +.research-dot .node-code{letter-spacing:0} .project-switcher{display:flex;align-items:center;gap:16px;padding:10px 32px;background:#143c32;color:#e6f4e9;min-width:0} .project-switcher[hidden]{display:none} .project-switcher>strong{font-size:11px;white-space:nowrap;letter-spacing:.4px} diff --git a/research_harness/static/app.js b/research_harness/static/app.js index 2abb628..365418c 100644 --- a/research_harness/static/app.js +++ b/research_harness/static/app.js @@ -66,7 +66,7 @@ function initialExpanded(){const ids=new Set();for(const n of Object.values(data words.zh.tree='大纲';words.en.tree='Outline';words.zh.relationsView='研究树';words.en.relationsView='Research tree'; let data, view='map', selected=null, layout='lineage', filter='', compareFilter='', sourceMode='mapped', cellSelection=new Set(), metric='success_rate', differentOnly=false; let relationInspect=false; -let treeCamera={x:0,y:0,scale:1},treeCollapsed=new Set(),treeSecondary=false,treeFullscreen=false,treeFitMode=false; +let treeCamera={x:0,y:0,scale:1},treeCollapsed=new Set(),treeSecondary=false,treeFullscreen=false,treeFitMode=false,treeShowAll=false; let expanded = new Set(), collapsedDuringSearch=new Set(), token='', savedKey='', sourcePage=0, historyType='all', historyModule='all', timelineView='project', projectTimelineModule='all', projectTimelineType='all', timelineReturnEvent=null, compareOpenGroups=new Set(); const snap = $('snapshot-data'); try { token = new URLSearchParams(location.search).get('token') || sessionStorage.getItem('rh-token') || ''; if(token)sessionStorage.setItem('rh-token',token); if(location.search.includes('token='))history.replaceState({},'',location.pathname); } catch (_) {} @@ -166,7 +166,23 @@ function renderMap() { } // BEGIN grouped-relation-model // This is only a display projection: no experimental identities or edges change. +function projectedCellRelations(nodeMap, edgeList) { + const owner=id=>{let n=nodeMap[id],seen=new Set();if(!n||!['cell','run'].includes(n.kind))return null;while(n&&!seen.has(n.id)){if(n.status==='archived')return null;seen.add(n.id);if(n.kind==='cell')return n.id;n=nodeMap[n.parent_id];}return null;}; + const out=new Map(); + for(const edge of edgeList){ + const source=owner(edge.source),target=owner(edge.target)||(edge.relation==='derived_from'?edge.target:null); + if(!source||!target||source===target)continue; + const projected=!!edge.projected||source!==edge.source||target!==edge.target; + if(projected&&!['derived_from','inspired_by','compares'].includes(edge.relation))continue; + const key=JSON.stringify([source,target,edge.relation]); + if(!out.has(key))out.set(key,{...edge,source,target,projected,originalEdges:[]}); + const item=out.get(key);item.projected=item.projected&&projected; + item.originalEdges.push(...(edge.originalEdges||[{id:edge.id,source:edge.source,target:edge.target}]).map(e=>({...e}))); + } + return [...out.values()]; +} function groupedRelationModel(nodeMap, edgeList) { + edgeList=projectedCellRelations(nodeMap,edgeList); const active=Object.values(nodeMap).filter(n=>n.status!=='archived'); const byId=new Map(active.map(n=>[n.id,n])); const owners=active.filter(n=>n.kind==='module'||n.kind==='question'); @@ -203,7 +219,16 @@ function groupedRelationModel(nodeMap, edgeList) { } // END grouped-relation-model // BEGIN research-tree-model -function researchTreeModel(nodeMap, edgeList, collapsed=new Set()) { +function resolveTreeFocus(nodeMap, focusId) { + if(typeof focusId!=='string')return null; + let n=nodeMap[focusId],seen=new Set(); + if(n?.kind==='run')while(n&&n.kind!=='cell'&&!seen.has(n.id)){if(n.status==='archived')return null;seen.add(n.id);n=nodeMap[n.parent_id];} + return n&&n.status!=='archived'&&['module','question','cell'].includes(n.kind)?n.id:null; +} +function recordedTreeFocus(nodeMap){return Object.values(nodeMap).filter(n=>n.status!=='archived').map(n=>resolveTreeFocus(nodeMap,n.attrs?.current_focus)).find(Boolean)||null;} +function researchTreeModel(nodeMap, edgeList, collapsed=new Set(), focusId=null) { + focusId=resolveTreeFocus(nodeMap,focusId); + edgeList=projectedCellRelations(nodeMap,edgeList); const grouped=groupedRelationModel(nodeMap,edgeList),cells=grouped.groups.flatMap(g=>g.cells),byId=new Map(cells.map(n=>[n.id,n])); const parent=new Map(),warnings=[],order=(a,b)=>(a.attrs?.order??100)-(b.attrs?.order??100)||a.id.localeCompare(b.id); for(const n of cells){ @@ -217,11 +242,19 @@ function researchTreeModel(nodeMap, edgeList, collapsed=new Set()) { for(const n of [...cells].sort(order)){let p=n.id,seen=new Set();while(parent.has(p)){if(seen.has(p)){parent.delete(p);warnings.push({id:p,type:'cycle'});break;}seen.add(p);p=parent.get(p);}} const depth=new Map(),level=id=>{if(depth.has(id))return depth.get(id);const d=parent.has(id)?level(parent.get(id))+1:0;depth.set(id,d);return d;}; cells.forEach(n=>level(n.id)); - const visible=cells.filter(n=>{let p=parent.get(n.id);while(p){if(collapsed.has(p))return false;p=parent.get(p);}return true;}),visibleIds=new Set(visible.map(n=>n.id)); + let scope=null; + if(focusId&&nodeMap[focusId]?.status!=='archived'&&nodeMap[focusId]){ + scope=new Set(); + for(const n of cells){let current=n,seen=new Set();while(current&&!seen.has(current.id)){seen.add(current.id);if(current.id===focusId){scope.add(n.id);break;}current=nodeMap[current.parent_id];}} + // Keep every recorded origin, including secondary parents, as context. + const pending=[...scope];while(pending.length){const id=pending.pop(),origins=[parent.get(id),...edgeList.filter(e=>e.source===id&&e.relation==='derived_from').map(e=>e.target)];for(const origin of origins)if(byId.has(origin)&&!scope.has(origin)){scope.add(origin);pending.push(origin);}} + } + const visible=cells.filter(n=>{if(scope&&!scope.has(n.id))return false;let p=parent.get(n.id);while(p){if(collapsed.has(p))return false;p=parent.get(p);}return true;}),visibleIds=new Set(visible.map(n=>n.id)); const children=new Map();for(const n of cells){const p=parent.get(n.id);if(p){if(!children.has(p))children.set(p,[]);children.get(p).push(n.id);}} const countDesc=id=>(children.get(id)||[]).reduce((sum,child)=>sum+1+countDesc(child),0); const nodes=[],lanes=[];let top=62; - for(const group of grouped.groups){ + const layoutGroups=scope?[{id:focusId,owner:nodeMap[focusId],cells:cells.filter(n=>scope.has(n.id))}]:grouped.groups; + for(const group of layoutGroups){ const members=group.cells.filter(n=>visibleIds.has(n.id));if(!members.length)continue; const memberIds=new Set(members.map(n=>n.id)),localChildren=new Map(); for(const n of members){const p=parent.get(n.id);if(memberIds.has(p)){if(!localChildren.has(p))localChildren.set(p,[]);localChildren.get(p).push(n);}} @@ -234,17 +267,19 @@ function researchTreeModel(nodeMap, edgeList, collapsed=new Set()) { for(const n of members)nodes.push({id:n.id,node:n,lane:group.id,level:level(n.id),x:190+level(n.id)*142,y:ys.get(n.id),hidden:collapsed.has(n.id)?countDesc(n.id):0}); top+=height; } - const links=nodes.filter(n=>parent.has(n.id)&&visibleIds.has(parent.get(n.id))).map(n=>({source:parent.get(n.id),target:n.id,secondary:false})); + const links=nodes.filter(n=>parent.has(n.id)&&visibleIds.has(parent.get(n.id))).map(n=>{const edge=edgeList.find(e=>e.relation==='derived_from'&&e.source===n.id&&e.target===parent.get(n.id));return {source:parent.get(n.id),target:n.id,secondary:false,projected:!!edge?.projected,originalEdges:edge?.originalEdges||[]};}); const secondary=edgeList.filter(e=>visibleIds.has(e.source)&&visibleIds.has(e.target)&&!(e.relation==='derived_from'&&parent.get(e.source)===e.target)).map(e=>({source:e.target,target:e.source,secondary:true,relation:e.relation})); - return {nodes,lanes,links,secondary,parent,warnings,width:Math.max(900,280+Math.max(0,...nodes.map(n=>n.level))*142),height:Math.max(340,top+20),total:cells.length}; + return {nodes,lanes,links,secondary,parent,warnings:warnings.filter(w=>visibleIds.has(w.id)),width:Math.max(900,280+Math.max(0,...nodes.map(n=>n.level))*142),height:Math.max(340,top+20),total:cells.length}; } // END research-tree-model +function currentTreeFocus(){return recordedTreeFocus(data.nodes);} +function displayedTreeModel(){return researchTreeModel(data.nodes,Object.values(data.edges),treeCollapsed,treeShowAll||filter?null:currentTreeFocus());} function treeOutcome(n){ if(n.status==='failed'||children(n.id).some(r=>r.kind==='run'&&(r.status==='failed'||r.attrs?.backend_correct===false)))return 'failed'; const selection=n.attrs?.selection;if(typeof selection==='string'){if(/^(保留|keep)/i.test(selection))return 'retained';if(/^(退步|同分|discard|tie)/i.test(selection))return 'unselected';} return 'recorded'; } -function shortTreeLabel(n){const text=displayLabel(n),match=text.match(/^[A-Za-z]+\d+\b/);if(match)return match[0];if(Number.isInteger(n.attrs?.generation))return 'G'+n.attrs.generation;const part=text.split('·')[0].trim();return part.length>11?part.slice(0,10)+'…':part;} +function shortTreeLabel(n){const text=displayLabel(n),match=text.match(/^[A-Za-z]+\d+\b/);if(match)return match[0];if(Number.isInteger(n.attrs?.generation))return 'G'+n.attrs.generation;const runs=children(n.id).filter(r=>r.kind==='run'),runId=runs[0]?.attrs?.id;if(typeof runId==='string'&&runId.length<=24)return runId+(runs.length>1?' +'+(runs.length-1):'');const part=text.split('·')[0].trim();return part.length>11?part.slice(0,10)+'…':part;} function treeNodeInspector(n){ if(!n)return ''; const local=(zh,en)=>lang==='zh'?zh:en; @@ -253,13 +288,13 @@ function treeNodeInspector(n){ return ``; } function renderResearchTree(){ - const model=researchTreeModel(data.nodes,Object.values(data.edges),treeCollapsed),positions=new Map(model.nodes.map(n=>[n.id,n])); + const model=displayedTreeModel(),positions=new Map(model.nodes.map(n=>[n.id,n])); const local=(zh,en)=>lang==='zh'?zh:en,n=relationInspect?data.nodes[selected]:null,path=new Set(); if(n){let id=n.id;while(id&&!path.has(id)){path.add(id);id=model.parent.get(id);}} const axes=Array.from({length:Math.max(0,...model.nodes.map(n=>n.level))+1},(_,i)=>`L${i}`).join(''); - const lanes=model.lanes.map((lane,i)=>{const name=lane.rootOnly?local('起点','Root'):lane.owner?displayLabel(lane.owner):local('未分组','Ungrouped');return `${esc(name)}${esc(name.length>15?name.slice(0,14)+'…':name)}`;}).join(''); + const lanes=model.lanes.map((lane,i)=>{const name=lane.owner?displayLabel(lane.owner):local('未分组','Ungrouped');return `${esc(name)}${esc(name.length>15?name.slice(0,14)+'…':name)}`;}).join(''); const edgePath=e=>{const a=positions.get(e.source),b=positions.get(e.target);const mid=(a.x+b.x)/2;return `M${a.x},${a.y} C${mid},${a.y} ${mid},${b.y} ${b.x},${b.y}`;}; - const lines=[...model.links,...(treeSecondary?model.secondary:[])].map(e=>``).join(''); + const lines=[...model.links,...(treeSecondary?model.secondary:[])].map(e=>`${esc(e.projected?local('运行谱系:','Run lineage: ')+(e.originalEdges||[]).map(x=>x.source+' → '+x.target).join('; '):local('已记录关系','Recorded relation'))}`).join(''); const dots=model.nodes.map(p=>{const match=!filter||(displayLabel(p.node)+' '+displayDescription(p.node)+' '+p.id).toLowerCase().includes(filter.toLowerCase()),outcome=treeOutcome(p.node);return `${esc(displayLabel(p.node))}${p.node.attrs?.selection?' — '+esc(p.node.attrs.selection):''}${outcome==='failed'?'':''}${esc(shortTreeLabel(p.node))}${p.hidden?`+${p.hidden}`:''}`;}).join(''); const maxLevel=Math.max(0,...model.nodes.map(n=>n.level)); const vertical=Array.from({length:maxLevel+1},(_,i)=>``).join(''); @@ -268,7 +303,16 @@ function renderResearchTree(){ function bindResearchTree(){ const svg=$('research-tree-svg'),canvas=$('research-canvas');if(!svg)return; svg.setAttribute('preserveAspectRatio',canvas.clientWidth<600&&!treeFitMode?'xMinYMin slice':'xMidYMid meet'); - const model=researchTreeModel(data.nodes,Object.values(data.edges),treeCollapsed); + const model=displayedTreeModel(); + canvas.classList.toggle('is-focused',!!currentTreeFocus()&&!treeShowAll&&!filter); + canvas.querySelectorAll('.node-code').forEach(text=>{if(text.getComputedTextLength()>112){text.setAttribute('textLength','112');text.setAttribute('lengthAdjust','spacingAndGlyphs');}}); + if(currentTreeFocus()){ + const label=document.createElement('label');label.className='tree-scope-control'; + const input=document.createElement('input');input.type='checkbox';input.checked=treeShowAll;input.id='tree-show-all'; + input.onchange=()=>{treeShowAll=input.checked;treeCamera={x:0,y:0,scale:1};drawMapBody();}; + label.append(input,document.createTextNode(lang==='zh'?'全部研究方向':'All research tracks')); + canvas.parentElement.querySelector('.tree-actions').prepend(label); + } const paint=()=>{$('research-tree-transform')?.setAttribute('transform',`translate(${treeCamera.x} ${treeCamera.y}) scale(${treeCamera.scale})`);const label=$('tree-zoom-value');if(label)label.textContent=Math.round(treeCamera.scale*100)+'%';}; const zoom=factor=>{const next=Math.max(.35,Math.min(3,treeCamera.scale*factor)),ratio=next/treeCamera.scale;treeCamera.x=model.width/2-(model.width/2-treeCamera.x)*ratio;treeCamera.y=model.height/2-(model.height/2-treeCamera.y)*ratio;treeCamera.scale=next;paint();}; $('tree-zoom-in').onclick=()=>zoom(1.25);$('tree-zoom-out').onclick=()=>zoom(.8); diff --git a/tests/test_organization.py b/tests/test_organization.py new file mode 100644 index 0000000..d3c0e6d --- /dev/null +++ b/tests/test_organization.py @@ -0,0 +1,92 @@ +import copy +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from research_harness.organization import organization +from research_harness.scan import node +from research_harness.server import export_html, presentation +from research_harness.store import Store + + +class OrganizationTests(unittest.TestCase): + def fixture(self): + nodes = {'study': {'id': 'study', 'kind': 'module', 'parent_id': 'project', 'attrs': {}}} + for i in range(6): + nodes[f'c{i}'] = {'id': f'c{i}', 'kind': 'cell', 'parent_id': 'study', 'attrs': {}} + nodes[f'r{i}'] = {'id': f'r{i}', 'kind': 'run', 'parent_id': f'c{i}', 'attrs': {}} + return {'nodes': nodes, 'edges': {}} + + def test_flat_list_is_readonly_review_hint(self): + state = self.fixture(); before = copy.deepcopy(state) + report = organization(state) + self.assertEqual({w['code'] for w in report['warnings']}, {'missing_study_synthesis', 'unexplained_condition_list'}) + self.assertEqual(state, before) + + def test_independent_design_needs_no_invented_lineage(self): + state = self.fixture() + state['nodes']['study']['attrs'] = {'design': {'factor': 'independent conditions'}, 'study_summary': {'en': {'intro': 'Independent design'}}} + self.assertFalse(organization(state)['needs_review']) + + def test_run_lineage_counts_as_recorded_comparison(self): + state = self.fixture() + state['edges']['edge'] = {'source': 'r1', 'target': 'r0', 'relation': 'derived_from'} + report = organization(state) + self.assertEqual(report['run_lineage_edges'], 1) + self.assertNotIn('unexplained_condition_list', {w['code'] for w in report['warnings']}) + + def test_archived_origins_are_not_current_comparisons(self): + state = self.fixture(); state['nodes']['r0']['status'] = 'archived' + state['edges']['edge'] = {'source': 'r1', 'target': 'r0', 'relation': 'derived_from'} + self.assertEqual(organization(state)['run_lineage_edges'], 0) + + def test_invalid_focus_and_wide_sections(self): + state = self.fixture(); state['nodes']['study']['attrs']['current_focus'] = ['invalid'] + for i in range(8): + state['nodes'][f'm{i}'] = {'id': f'm{i}', 'kind': 'module', 'parent_id': 'study', 'attrs': {}} + codes = {w['code'] for w in organization(state)['warnings']} + self.assertIn('invalid_current_focus', codes); self.assertIn('wide_research_outline', codes) + + def test_run_focus_requires_visible_owning_cell(self): + state = self.fixture() + state['nodes']['study']['attrs']['current_focus'] = 'r1' + self.assertEqual(organization(state)['current_focus_ids'], ['r1']) + state['nodes']['c1']['status'] = 'archived' + self.assertIn('invalid_current_focus', {w['code'] for w in organization(state)['warnings']}) + + def test_nonrenderable_focus_is_reported(self): + state = self.fixture() + state['nodes']['eval'] = {'id': 'eval', 'kind': 'evaluation', 'parent_id': 'r1', 'attrs': {}} + state['nodes']['study']['attrs']['current_focus'] = 'eval' + self.assertIn('invalid_current_focus', {w['code'] for w in organization(state)['warnings']}) + + def test_cli_live_and_export_share_readonly_review(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + store = Store(root) + state = store.init('Synthetic organization regression') + nodes = [node('study', 'module', 'Independent conditions', state['project']['id'])] + nodes.extend(node(f'c{i}', 'cell', f'Condition {i}', 'study') for i in range(6)) + store.commit({'base_revision': state['revision'], 'reason': 'Synthetic flat list', 'nodes': nodes}) + before = store.load() + expected = organization(before) + self.assertTrue(expected['needs_review']) + cli = Path(__file__).resolve().parents[1] / 'labweft.py' + for command in ['context', 'validate']: + result = subprocess.run([sys.executable, str(cli), command, str(root)], + capture_output=True, check=True) + payload = json.loads(result.stdout.decode('utf-8')) + self.assertEqual(payload['organization'], expected) + if command == 'validate': + self.assertTrue(payload['valid']) + self.assertEqual(presentation(store)['organization'], expected) + output = root / 'snapshot.html' + export_html(store, output) + packed = re.search(r'', + output.read_text(encoding='utf-8'), re.DOTALL) + self.assertIsNotNone(packed) + self.assertEqual(json.loads(packed.group(1))['organization'], expected) + self.assertEqual(store.load(), before) diff --git a/tests/test_research_tree.cjs b/tests/test_research_tree.cjs index b7aaf11..a9bb563 100644 --- a/tests/test_research_tree.cjs +++ b/tests/test_research_tree.cjs @@ -34,3 +34,65 @@ test('inspiration is optional secondary context, not a fabricated primary genera const {nodes,edges}=fixture();edges.push({source:'a1',target:'b1',relation:'inspired_by'}); const m=build(nodes,edges);assert.equal(m.nodes.find(n=>n.id==='a1').level,1);assert.equal(m.secondary.length,1); }); + +test('run provenance connects conditions without duplicating runs or inventing replication levels',()=>{ + const nodes=Object.fromEntries([node('study','module','project'),node('base','cell','study'),node('next','cell','study'),node('r1','run','base'),node('r2','run','next'),node('r3','run','next')].map(n=>[n.id,n])); + const edges=[{id:'e1',source:'r2',target:'r1',relation:'derived_from'},{id:'e2',source:'r3',target:'r1',relation:'derived_from'},{id:'replicate',source:'r3',target:'r2',relation:'derived_from'}]; + const before=JSON.stringify({nodes,edges}),m=build(nodes,edges); + assert.equal(m.nodes.length,2);assert.equal(m.links.length,1);assert.equal(m.nodes.find(n=>n.id==='next').level,1); + assert.equal(m.links[0].projected,true);assert.equal(m.links[0].originalEdges.length,2); + assert.equal(m.links[0].originalEdges[0].source,'r2');assert.equal(JSON.stringify({nodes,edges}),before); +}); + +test('focus retains origins and excludes unrelated history; all tracks remain available',()=>{ + const {nodes,edges}=fixture();nodes.focus=node('focus','module','project');nodes.current=node('current','cell','focus');edges.push({source:'current',target:'a2',relation:'derived_from'}); + const scoped=build(nodes,edges,new Set(),'focus'); + assert.deepEqual(Array.from(scoped.nodes,n=>n.id).sort(),['a1','a2','base','current']); + assert.equal(scoped.total,6);assert.equal(build(nodes,edges).nodes.length,6); + assert.equal(scoped.lanes.length,1);assert.equal(scoped.lanes[0].owner.id,'focus'); + assert.equal(build(nodes,edges,new Set(),'missing').nodes.length,6); +}); + +test('missing explicit ancestry is warned rather than replaced with hierarchy',()=>{ + const {nodes,edges}=fixture();nodes.a1.parent_id='base';edges.splice(0,1,{source:'a1',target:'missing',relation:'derived_from'}); + const m=build(nodes,edges);assert.equal(m.parent.has('a1'),false);assert.ok(m.warnings.some(w=>w.id==='a1'&&w.type==='missing_parent')); +}); + +test('different run origins remain multiple parents, not an invented clean chain',()=>{ + const {nodes,edges}=fixture();nodes.r1=node('r1','run','a1');nodes.r2=node('r2','run','b1');nodes.r3=node('r3','run','a3');nodes.r4=node('r4','run','a3'); + edges.push({source:'r3',target:'r1',relation:'derived_from'},{source:'r4',target:'r2',relation:'derived_from'}); + const m=build(nodes,edges);assert.ok(m.warnings.some(w=>w.id==='a3'&&w.type==='multiple_parents'));assert.ok(m.secondary.some(e=>e.target==='a3')); +}); + +test('focusing a run displays its owning condition and recorded origins',()=>{ + const {nodes,edges}=fixture();nodes.r1=node('r1','run','a2'); + const m=build(nodes,edges,new Set(),'r1'); + assert.deepEqual(Array.from(m.nodes,n=>n.id).sort(),['a1','a2','base']); + assert.equal(m.lanes[0].owner.id,'a2'); +}); + +test('focused condition preserves hierarchy ancestry when no relation was recorded',()=>{ + const {nodes}=fixture();nodes.a1.parent_id='base';nodes.a2.parent_id='a1'; + const m=build(nodes,[],new Set(),'a2'); + assert.deepEqual(Array.from(m.nodes,n=>n.id).sort(),['a1','a2','base']); + assert.equal(m.links.length,2); +}); + +test('archived declarations and unsupported focus targets cannot hide the tree',()=>{ + const {nodes,edges}=fixture();nodes.study.status='archived';nodes.study.attrs.current_focus='a1'; + nodes.A.attrs.current_focus='a2'; + assert.equal(context.recordedTreeFocus(nodes),'a2'); + nodes.eval=node('eval','evaluation','a2');nodes.A.attrs.current_focus='eval'; + assert.equal(context.recordedTreeFocus(nodes),null); + assert.equal(build(nodes,edges,new Set(),'eval').nodes.length,5); + nodes.r1=node('r1','run','a1');nodes.a1.status='archived'; + assert.equal(context.resolveTreeFocus(nodes,'r1'),null); +}); + +test('reprojecting relations preserves original run provenance',()=>{ + const {nodes}=fixture();nodes.r1=node('r1','run','a1');nodes.r2=node('r2','run','a2'); + const edges=[{id:'origin',source:'r2',target:'r1',relation:'derived_from'}]; + const once=context.projectedCellRelations(nodes,edges),before=JSON.stringify(once); + const twice=context.projectedCellRelations(nodes,once); + assert.equal(JSON.stringify(twice),before);assert.equal(JSON.stringify(once),before); +}); From 2bc2112985bd1a3c3d114a10b693e532b5dd61f6 Mon Sep 17 00:00:00 2001 From: WGeorgezzzz <257936154+WGeorgezzzz@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:10:46 +0800 Subject: [PATCH 2/2] Normalize resolved paths in cross-platform tests --- tests/test_harness.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_harness.py b/tests/test_harness.py index 874d5b6..3b5effe 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -63,7 +63,7 @@ def test_bootstrap_can_keep_state_outside_source(self): "--workspace", str(workspace), "--agent", "codex"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) payload = json.loads(result.stdout.decode("utf-8")) - self.assertEqual(str(workspace), payload["workspace"]) + self.assertEqual(str(workspace.resolve()), payload["workspace"]) self.assertFalse((source / ".research").exists()) self.assertFalse((source / ".agents").exists()) self.assertTrue((workspace / ".agents" / "skills" / "research-harness" / "SKILL.md").is_file()) @@ -633,7 +633,7 @@ def test_export_site_keeps_project_snapshots_separate_and_switchable(self): sync(other) output = Path(self.temp.name) / "site" result = export_site([self.root, other_root], output, include_evidence=True) - self.assertEqual(str(output / "index.html"), result["index"]) + self.assertEqual(str((output / "index.html").resolve()), result["index"]) pages = [output / "index.html", *[Path(item["page"]) for item in result["projects"]]] payloads = [] for page in pages: