homegraph 1.1.3 → 1.2.0

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.
Files changed (246) hide show
  1. package/README.md +43 -18
  2. package/dist/arkts/ohos-api-index.d.ts +15 -0
  3. package/dist/arkts/ohos-api-index.d.ts.map +1 -0
  4. package/dist/arkts/ohos-api-index.js +190 -0
  5. package/dist/arkts/ohos-api-index.js.map +1 -0
  6. package/dist/arkts/ohos-sdk-input.d.ts +36 -0
  7. package/dist/arkts/ohos-sdk-input.d.ts.map +1 -0
  8. package/dist/arkts/ohos-sdk-input.js +214 -0
  9. package/dist/arkts/ohos-sdk-input.js.map +1 -0
  10. package/dist/bin/command-supervision.d.ts +12 -0
  11. package/dist/bin/command-supervision.d.ts.map +1 -0
  12. package/dist/bin/command-supervision.js +86 -0
  13. package/dist/bin/command-supervision.js.map +1 -0
  14. package/dist/bin/homegraph.d.ts +1 -1
  15. package/dist/bin/homegraph.js +706 -168
  16. package/dist/bin/homegraph.js.map +1 -1
  17. package/dist/db/index.d.ts +20 -0
  18. package/dist/db/index.d.ts.map +1 -1
  19. package/dist/db/index.js +39 -0
  20. package/dist/db/index.js.map +1 -1
  21. package/dist/db/migrations.d.ts +1 -1
  22. package/dist/db/migrations.d.ts.map +1 -1
  23. package/dist/db/migrations.js +40 -1
  24. package/dist/db/migrations.js.map +1 -1
  25. package/dist/db/queries.d.ts +14 -0
  26. package/dist/db/queries.d.ts.map +1 -1
  27. package/dist/db/queries.js +174 -3
  28. package/dist/db/queries.js.map +1 -1
  29. package/dist/db/schema.sql +20 -0
  30. package/dist/directory.d.ts +32 -0
  31. package/dist/directory.d.ts.map +1 -1
  32. package/dist/directory.js +83 -0
  33. package/dist/directory.js.map +1 -1
  34. package/dist/extraction/arkts-batch-worker.d.ts +2 -0
  35. package/dist/extraction/arkts-batch-worker.d.ts.map +1 -0
  36. package/dist/extraction/arkts-batch-worker.js +28 -0
  37. package/dist/extraction/arkts-batch-worker.js.map +1 -0
  38. package/dist/extraction/context.d.ts +11 -0
  39. package/dist/extraction/context.d.ts.map +1 -1
  40. package/dist/extraction/context.js +20 -0
  41. package/dist/extraction/context.js.map +1 -1
  42. package/dist/extraction/index.d.ts +16 -2
  43. package/dist/extraction/index.d.ts.map +1 -1
  44. package/dist/extraction/index.js +619 -380
  45. package/dist/extraction/index.js.map +1 -1
  46. package/dist/extraction/languages/arkts-state-decorators.d.ts +13 -0
  47. package/dist/extraction/languages/arkts-state-decorators.d.ts.map +1 -0
  48. package/dist/extraction/languages/arkts-state-decorators.js +26 -0
  49. package/dist/extraction/languages/arkts-state-decorators.js.map +1 -0
  50. package/dist/extraction/languages/arkts-viewtree.d.ts +4 -2
  51. package/dist/extraction/languages/arkts-viewtree.d.ts.map +1 -1
  52. package/dist/extraction/languages/arkts-viewtree.js +21 -6
  53. package/dist/extraction/languages/arkts-viewtree.js.map +1 -1
  54. package/dist/extraction/languages/arkts.d.ts +98 -2
  55. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  56. package/dist/extraction/languages/arkts.js +1290 -61
  57. package/dist/extraction/languages/arkts.js.map +1 -1
  58. package/dist/extraction/languages/c-cpp.d.ts +56 -0
  59. package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  60. package/dist/extraction/languages/c-cpp.js +198 -1
  61. package/dist/extraction/languages/c-cpp.js.map +1 -1
  62. package/dist/extraction/languages/ohos-api-consumer.d.ts +34 -0
  63. package/dist/extraction/languages/ohos-api-consumer.d.ts.map +1 -0
  64. package/dist/extraction/languages/ohos-api-consumer.js +283 -0
  65. package/dist/extraction/languages/ohos-api-consumer.js.map +1 -0
  66. package/dist/extraction/parse-pool.d.ts +126 -0
  67. package/dist/extraction/parse-pool.d.ts.map +1 -0
  68. package/dist/extraction/parse-pool.js +319 -0
  69. package/dist/extraction/parse-pool.js.map +1 -0
  70. package/dist/extraction/tree-sitter-types.d.ts +17 -0
  71. package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  72. package/dist/extraction/tree-sitter.d.ts +21 -0
  73. package/dist/extraction/tree-sitter.d.ts.map +1 -1
  74. package/dist/extraction/tree-sitter.js +198 -27
  75. package/dist/extraction/tree-sitter.js.map +1 -1
  76. package/dist/graph/traversal.d.ts.map +1 -1
  77. package/dist/graph/traversal.js +76 -17
  78. package/dist/graph/traversal.js.map +1 -1
  79. package/dist/index.d.ts +25 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +78 -4
  82. package/dist/index.js.map +1 -1
  83. package/dist/mcp/daemon-paths.d.ts +30 -3
  84. package/dist/mcp/daemon-paths.d.ts.map +1 -1
  85. package/dist/mcp/daemon-paths.js +50 -10
  86. package/dist/mcp/daemon-paths.js.map +1 -1
  87. package/dist/mcp/daemon-registry.d.ts.map +1 -1
  88. package/dist/mcp/daemon-registry.js +7 -3
  89. package/dist/mcp/daemon-registry.js.map +1 -1
  90. package/dist/mcp/daemon.d.ts +48 -0
  91. package/dist/mcp/daemon.d.ts.map +1 -1
  92. package/dist/mcp/daemon.js +196 -32
  93. package/dist/mcp/daemon.js.map +1 -1
  94. package/dist/mcp/engine.d.ts +17 -0
  95. package/dist/mcp/engine.d.ts.map +1 -1
  96. package/dist/mcp/engine.js +73 -1
  97. package/dist/mcp/engine.js.map +1 -1
  98. package/dist/mcp/index.d.ts.map +1 -1
  99. package/dist/mcp/index.js +25 -43
  100. package/dist/mcp/index.js.map +1 -1
  101. package/dist/mcp/ppid-watchdog.d.ts +18 -0
  102. package/dist/mcp/ppid-watchdog.d.ts.map +1 -1
  103. package/dist/mcp/ppid-watchdog.js +37 -0
  104. package/dist/mcp/ppid-watchdog.js.map +1 -1
  105. package/dist/mcp/query-cache.d.ts +25 -0
  106. package/dist/mcp/query-cache.d.ts.map +1 -0
  107. package/dist/mcp/query-cache.js +191 -0
  108. package/dist/mcp/query-cache.js.map +1 -0
  109. package/dist/mcp/query-pool.d.ts +94 -0
  110. package/dist/mcp/query-pool.d.ts.map +1 -0
  111. package/dist/mcp/query-pool.js +297 -0
  112. package/dist/mcp/query-pool.js.map +1 -0
  113. package/dist/mcp/query-worker.d.ts +24 -0
  114. package/dist/mcp/query-worker.d.ts.map +1 -0
  115. package/dist/mcp/query-worker.js +87 -0
  116. package/dist/mcp/query-worker.js.map +1 -0
  117. package/dist/mcp/server-instructions.d.ts +5 -7
  118. package/dist/mcp/server-instructions.d.ts.map +1 -1
  119. package/dist/mcp/server-instructions.js +12 -14
  120. package/dist/mcp/server-instructions.js.map +1 -1
  121. package/dist/mcp/tools.d.ts +77 -0
  122. package/dist/mcp/tools.d.ts.map +1 -1
  123. package/dist/mcp/tools.js +696 -60
  124. package/dist/mcp/tools.js.map +1 -1
  125. package/dist/project-config.d.ts +20 -0
  126. package/dist/project-config.d.ts.map +1 -1
  127. package/dist/project-config.js +42 -2
  128. package/dist/project-config.js.map +1 -1
  129. package/dist/reasoning/login.js +1 -1
  130. package/dist/reasoning/login.js.map +1 -1
  131. package/dist/resolution/c-fnptr-synthesizer.d.ts +0 -28
  132. package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
  133. package/dist/resolution/c-fnptr-synthesizer.js +765 -79
  134. package/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
  135. package/dist/resolution/callback-synthesizer.d.ts +1 -1
  136. package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  137. package/dist/resolution/callback-synthesizer.js +72 -11
  138. package/dist/resolution/callback-synthesizer.js.map +1 -1
  139. package/dist/resolution/cooperative-yield.d.ts +32 -0
  140. package/dist/resolution/cooperative-yield.d.ts.map +1 -0
  141. package/dist/resolution/cooperative-yield.js +42 -0
  142. package/dist/resolution/cooperative-yield.js.map +1 -0
  143. package/dist/resolution/index.d.ts +11 -2
  144. package/dist/resolution/index.d.ts.map +1 -1
  145. package/dist/resolution/index.js +72 -4
  146. package/dist/resolution/index.js.map +1 -1
  147. package/dist/resolution/name-matcher.d.ts +22 -0
  148. package/dist/resolution/name-matcher.d.ts.map +1 -1
  149. package/dist/resolution/name-matcher.js +317 -20
  150. package/dist/resolution/name-matcher.js.map +1 -1
  151. package/dist/spec/config.d.ts +7 -11
  152. package/dist/spec/config.d.ts.map +1 -1
  153. package/dist/spec/config.js +77 -48
  154. package/dist/spec/config.js.map +1 -1
  155. package/dist/spec/db/fragment-node.d.ts +1 -0
  156. package/dist/spec/db/fragment-node.d.ts.map +1 -1
  157. package/dist/spec/db/fragment-node.js +8 -0
  158. package/dist/spec/db/fragment-node.js.map +1 -1
  159. package/dist/spec/db/fts.d.ts +14 -0
  160. package/dist/spec/db/fts.d.ts.map +1 -1
  161. package/dist/spec/db/fts.js +43 -4
  162. package/dist/spec/db/fts.js.map +1 -1
  163. package/dist/spec/db/schema.d.ts +6 -3
  164. package/dist/spec/db/schema.d.ts.map +1 -1
  165. package/dist/spec/db/schema.js +36 -4
  166. package/dist/spec/db/schema.js.map +1 -1
  167. package/dist/spec/db/schema.sql +11 -1
  168. package/dist/spec/evolve/logic-checker.d.ts +2 -2
  169. package/dist/spec/evolve/logic-checker.d.ts.map +1 -1
  170. package/dist/spec/evolve/logic-checker.js +3 -27
  171. package/dist/spec/evolve/logic-checker.js.map +1 -1
  172. package/dist/spec/evolve/pipeline.d.ts +21 -19
  173. package/dist/spec/evolve/pipeline.d.ts.map +1 -1
  174. package/dist/spec/evolve/pipeline.js +284 -31
  175. package/dist/spec/evolve/pipeline.js.map +1 -1
  176. package/dist/spec/evolve/spec-rewriter.d.ts +2 -2
  177. package/dist/spec/evolve/spec-rewriter.d.ts.map +1 -1
  178. package/dist/spec/evolve/spec-rewriter.js +7 -31
  179. package/dist/spec/evolve/spec-rewriter.js.map +1 -1
  180. package/dist/spec/graph/queries.d.ts +97 -0
  181. package/dist/spec/graph/queries.d.ts.map +1 -1
  182. package/dist/spec/graph/queries.js +225 -0
  183. package/dist/spec/graph/queries.js.map +1 -1
  184. package/dist/spec/llm/client.d.ts +29 -0
  185. package/dist/spec/llm/client.d.ts.map +1 -0
  186. package/dist/spec/llm/client.js +123 -0
  187. package/dist/spec/llm/client.js.map +1 -0
  188. package/dist/spec/llm/index.d.ts +3 -0
  189. package/dist/spec/llm/index.d.ts.map +1 -0
  190. package/dist/spec/llm/index.js +11 -0
  191. package/dist/spec/llm/index.js.map +1 -0
  192. package/dist/spec/llm/prompts.d.ts +13 -0
  193. package/dist/spec/llm/prompts.d.ts.map +1 -0
  194. package/dist/spec/llm/prompts.js +75 -0
  195. package/dist/spec/llm/prompts.js.map +1 -0
  196. package/dist/spec/mining/git-scanner.d.ts +12 -0
  197. package/dist/spec/mining/git-scanner.d.ts.map +1 -1
  198. package/dist/spec/mining/git-scanner.js +41 -0
  199. package/dist/spec/mining/git-scanner.js.map +1 -1
  200. package/dist/spec/mining/pipeline.d.ts.map +1 -1
  201. package/dist/spec/mining/pipeline.js +14 -1
  202. package/dist/spec/mining/pipeline.js.map +1 -1
  203. package/dist/spec/utils.d.ts +2 -1
  204. package/dist/spec/utils.d.ts.map +1 -1
  205. package/dist/spec/utils.js +3 -1
  206. package/dist/spec/utils.js.map +1 -1
  207. package/dist/sync/worktree.d.ts +9 -0
  208. package/dist/sync/worktree.d.ts.map +1 -1
  209. package/dist/sync/worktree.js +40 -0
  210. package/dist/sync/worktree.js.map +1 -1
  211. package/dist/types.d.ts +6 -1
  212. package/dist/types.d.ts.map +1 -1
  213. package/dist/ui/shimmer-progress.d.ts +2 -0
  214. package/dist/ui/shimmer-progress.d.ts.map +1 -1
  215. package/dist/ui/shimmer-progress.js +19 -2
  216. package/dist/ui/shimmer-progress.js.map +1 -1
  217. package/dist/upgrade/index.js +1 -1
  218. package/dist/upgrade/index.js.map +1 -1
  219. package/package.json +3 -3
  220. package/scripts/agent-eval/repro-concurrent-explore.mjs +119 -0
  221. package/scripts/agent-eval/repro-daemon-clients.mjs +125 -0
  222. package/scripts/exp_boundary_eval/README.md +247 -0
  223. package/scripts/exp_boundary_eval/__pycache__/_utils.cpython-310.pyc +0 -0
  224. package/scripts/exp_boundary_eval/__pycache__/analyze.cpython-310.pyc +0 -0
  225. package/scripts/exp_boundary_eval/__pycache__/deveco_arm.cpython-310.pyc +0 -0
  226. package/scripts/exp_boundary_eval/__pycache__/run_one.cpython-310.pyc +0 -0
  227. package/scripts/exp_boundary_eval/__pycache__/run_session.cpython-310.pyc +0 -0
  228. package/scripts/exp_boundary_eval/__pycache__/setup.cpython-310.pyc +0 -0
  229. package/scripts/exp_boundary_eval/_test_mcp_chain.py +78 -0
  230. package/scripts/exp_boundary_eval/_test_stdin.py +8 -0
  231. package/scripts/exp_boundary_eval/_utils.py +1116 -0
  232. package/scripts/exp_boundary_eval/analyze.py +1313 -0
  233. package/scripts/exp_boundary_eval/data/agents.json +109 -0
  234. package/scripts/exp_boundary_eval/data/experiments.json +140 -0
  235. package/scripts/exp_boundary_eval/deveco_arm.py +519 -0
  236. package/scripts/exp_boundary_eval/run_all.py +378 -0
  237. package/scripts/exp_boundary_eval/run_one.py +165 -0
  238. package/scripts/exp_boundary_eval/run_session.py +158 -0
  239. package/scripts/exp_boundary_eval/setup.py +120 -0
  240. package/scripts/exp_boundary_eval/win_mcp_launcher.py +73 -0
  241. package/scripts/exp_boundary_eval/win_mcp_stdio_wrap.js +36 -0
  242. package/scripts/exp_boundary_eval/win_node_launcher.py +24 -0
  243. package/scripts/npm-shim.js +25 -3
  244. package/scripts/ohos-sdk-publish.mjs +133 -0
  245. package/scripts/qa_eval/README.md +1 -1
  246. package/scripts/qa_eval/agent_runner.py +1 -1
@@ -0,0 +1,1313 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Analyze experiment results and produce:
4
+ 1. Terminal summary
5
+ 2. Markdown report → results/analysis_report.md
6
+
7
+ The report includes narrative analysis, pattern detection, risk assessment,
8
+ and a scoring system — not just raw data tables.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ import sys
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import List, Optional
17
+
18
+ from _utils import (OUTPUT_DIR, GREEN, NC, header, is_homegraph_tool, parse_output,
19
+ parse_stream_json, read_run_manifest, resolve_deveco_model)
20
+
21
+ RESULTS_DIR = OUTPUT_DIR # default when no path argument given
22
+
23
+
24
+ def _configure_stdout():
25
+ """Avoid UnicodeEncodeError on Windows consoles (default GBK)."""
26
+ for stream in (sys.stdout, sys.stderr):
27
+ reconfigure = getattr(stream, "reconfigure", None)
28
+ if reconfigure is not None:
29
+ try:
30
+ reconfigure(encoding="utf-8", errors="replace")
31
+ except (OSError, ValueError):
32
+ pass
33
+
34
+
35
+ def _memory_from_result(d: dict) -> dict:
36
+ mem = d.get("memory") or {}
37
+ return {
38
+ "peak_rss_mb": mem.get("peak_rss_mb", 0),
39
+ "peak_homegraph_rss_mb": mem.get("peak_homegraph_rss_mb", 0),
40
+ "peak_combined_rss_mb": mem.get("peak_combined_rss_mb", 0),
41
+ }
42
+
43
+
44
+ def _format_mb(val) -> str:
45
+ if not val:
46
+ return "—"
47
+ return f"{float(val):.0f} MB"
48
+
49
+
50
+ def _parser_for_agent(agent: str) -> str:
51
+ a = agent.lower()
52
+ if "deveco" in a:
53
+ return "deveco_json"
54
+ if "codex" in a:
55
+ return "codex_json"
56
+ if "opencode" in a:
57
+ return "opencode_json"
58
+ if "cursor" in a:
59
+ return "cursor_stream_json"
60
+ return "claude_stream_json"
61
+
62
+
63
+ def _stream_stats(stream: Path, agent: str = ""):
64
+ parser = _parser_for_agent(agent) if agent else "auto"
65
+ return parse_output(stream, parser)
66
+
67
+
68
+ def _is_placeholder_model(model: str) -> bool:
69
+ return not model or model in ("?", "--", "unknown", "Unknown")
70
+
71
+
72
+ def _is_session_id(model: str) -> bool:
73
+ return bool(model) and model.startswith("ses_")
74
+
75
+
76
+ def format_model_display(model: str) -> str:
77
+ if _is_placeholder_model(model):
78
+ return "未知"
79
+ if _is_session_id(model):
80
+ return "未知(DevEco session 未导出)"
81
+ return model
82
+
83
+
84
+ def _normalize_model(raw: dict, exp_dir: Path, agent: str) -> str:
85
+ model = raw.get("model", "")
86
+ if not _is_placeholder_model(model) and not _is_session_id(model):
87
+ return model
88
+ if "deveco" not in agent.lower():
89
+ return model or "?"
90
+ deveco_sid = raw.get("deveco_session_id", "")
91
+ if not _is_session_id(deveco_sid):
92
+ deveco_sid = model if _is_session_id(model) else ""
93
+ if not deveco_sid:
94
+ stream = exp_dir / "stream_output.jsonl"
95
+ if not stream.exists():
96
+ stream = exp_dir / "round_1" / "stream_output.jsonl"
97
+ if stream.exists():
98
+ stats = parse_output(stream, "deveco_json")
99
+ deveco_sid = stats.deveco_session_id or (
100
+ stats.model if _is_session_id(stats.model) else ""
101
+ )
102
+ if deveco_sid:
103
+ resolved = resolve_deveco_model(deveco_sid)
104
+ if resolved:
105
+ return resolved
106
+ return model or "?"
107
+
108
+
109
+ def resolve_primary_model(summary: List[dict]) -> str:
110
+ """Pick the most common real model name across oneshot experiments."""
111
+ real = [r["model"] for r in summary
112
+ if not _is_placeholder_model(r.get("model", "")) and not _is_session_id(r["model"])]
113
+ if real:
114
+ return max(set(real), key=real.count)
115
+ fallback = [r["model"] for r in summary if not _is_placeholder_model(r.get("model", ""))]
116
+ if fallback:
117
+ return format_model_display(fallback[0])
118
+ return "未知"
119
+
120
+
121
+ def _has_experiment_outputs(path: Path) -> bool:
122
+ """True if path looks like a directory of per-experiment result folders."""
123
+ if not path.is_dir():
124
+ return False
125
+ for exp_id in ("1-1", "2", "5"):
126
+ if (path / exp_id / "results.json").exists() or (path / exp_id / "session_results.json").exists():
127
+ return True
128
+ return False
129
+
130
+
131
+ def resolve_results_dir(path: Path) -> Path:
132
+ """Accept artifact run root or results/ path; return the results directory."""
133
+ if _has_experiment_outputs(path):
134
+ return path
135
+ nested = path / "results"
136
+ if _has_experiment_outputs(nested):
137
+ return nested
138
+ return path
139
+
140
+
141
+ def resolve_report_path(input_path: Path, results_dir: Path) -> Path:
142
+ """Write analysis_report.md at artifact run root when layout allows."""
143
+ if input_path.resolve() != results_dir.resolve():
144
+ return input_path / "analysis_report.md"
145
+ parent = results_dir.parent
146
+ if (parent / "state").is_dir():
147
+ return parent / "analysis_report.md"
148
+ return results_dir / "analysis_report.md"
149
+
150
+
151
+ # ═══════════════════════════════════════════════════════════
152
+ # Data collection (raw metrics from experiment outputs)
153
+ # ═══════════════════════════════════════════════════════════
154
+
155
+ def collect_summary(results_dir: Path) -> List[dict]:
156
+ rows = []
157
+ for exp_dir in sorted(results_dir.iterdir()):
158
+ if not exp_dir.is_dir(): continue
159
+ exp_id = exp_dir.name
160
+ rj, sj = exp_dir / "results.json", exp_dir / "session_results.json"
161
+ if rj.exists():
162
+ d = json.loads(rj.read_text(encoding="utf-8"))
163
+ fr = d.get("files_read", [])
164
+ fe = d.get("modified_files", d.get("files_edited", 0))
165
+ if isinstance(fe, list): fe = len(fe)
166
+ agent = d.get("agent", "?")
167
+ stream = exp_dir / "stream_output.jsonl"
168
+ stats = _stream_stats(stream, agent) if stream.exists() else None
169
+ tool_calls = d.get("tool_calls", 0)
170
+ files_read = len(fr) if isinstance(fr, list) else fr
171
+ input_tokens = d.get("input_tokens", 0)
172
+ if stats and stats.tool_calls:
173
+ tool_calls = stats.tool_calls
174
+ files_read = len(set(stats.files_read))
175
+ if stats.total_input_tokens:
176
+ input_tokens = stats.total_input_tokens
177
+ if not fe and stats.files_edited:
178
+ fe = len(set(stats.files_edited))
179
+ tool_names = d.get("tool_names", [])
180
+ if stats and stats.tool_names:
181
+ tool_names = stats.tool_names
182
+ hg_tools = _homegraph_tool_counts(tool_names)
183
+ hg_tc = d.get("homegraph_tool_calls", sum(hg_tools.values()))
184
+ if stats and stats.homegraph_tool_calls:
185
+ hg_tc = stats.homegraph_tool_calls
186
+ rows.append(dict(id=exp_id, title=d.get("title", "?"), type="oneshot",
187
+ duration_s=d.get("duration_s", "?"), tool_calls=tool_calls,
188
+ files_read=files_read, files_edited=fe,
189
+ input_tokens=input_tokens, max_turns_hit=d.get("max_turns_hit", False),
190
+ exit_code=d.get("exit_code", "?"), model=_normalize_model(d, exp_dir, agent),
191
+ tool_names=tool_names, errors=d.get("errors", []),
192
+ homegraph_tool_calls=hg_tc,
193
+ homegraph_tools=hg_tools,
194
+ homegraph_effective_calls=d.get("homegraph_effective_calls", 0),
195
+ homegraph_failed_calls=d.get("homegraph_failed_calls", 0),
196
+ used_homegraph=d.get("used_homegraph",
197
+ stats.used_homegraph if stats else bool(hg_tc)),
198
+ agent=agent, memory=_memory_from_result(d)))
199
+ elif sj.exists():
200
+ d = json.loads(sj.read_text(encoding="utf-8"))
201
+ agent = d.get("agent", "?")
202
+ total_tc = d.get("total_tool_calls", 0)
203
+ round_streams = sorted(exp_dir.glob("round_*/stream_output.jsonl"))
204
+ if round_streams:
205
+ total_tc = sum(_stream_stats(s, agent).tool_calls for s in round_streams)
206
+ model = _normalize_model(d, exp_dir, agent)
207
+ if _is_placeholder_model(model) or model == "?":
208
+ r1 = exp_dir / "round_1" / "stream_output.jsonl"
209
+ if r1.exists():
210
+ m = _stream_stats(r1, agent).model
211
+ if m:
212
+ model = m
213
+ rows.append(dict(id=exp_id, title="Session Persistence", type="session",
214
+ duration_s=f"{d.get('total_duration_ms', 0) / 1000:.0f}s",
215
+ tool_calls=total_tc, files_read="--", files_edited="--",
216
+ input_tokens=0, max_turns_hit=False, exit_code="--",
217
+ model=model,
218
+ tool_names=[], errors=[], agent=agent))
219
+ return rows
220
+
221
+ def collect_hallucination(results_dir: Path) -> dict:
222
+ r = {"exp": "3-1", "has_results": False, "tool_calls": 0, "files_explored": 0,
223
+ "hallucinated": False, "fab_score": 0, "pattern": "none",
224
+ "text_excerpt": "", "qualifiers": [], "real_code_explored": []}
225
+ stream = results_dir / "3-1" / "stream_output.jsonl"
226
+ if not stream.exists(): return r
227
+ r["has_results"] = True
228
+ agent = "?"
229
+ rj = results_dir / "3-1" / "results.json"
230
+ if rj.exists():
231
+ agent = json.loads(rj.read_text(encoding="utf-8")).get("agent", "?")
232
+ stats = _stream_stats(stream, agent)
233
+ r["tool_calls"] = stats.tool_calls
234
+ r["files_explored"] = len(set(stats.files_read))
235
+ text = "\n".join(stats.text_lines)
236
+ r["text_excerpt"] = text[:600]
237
+ r["module_mentioned"] = "PhotoWatermarkEngine" in text
238
+ # Fabrication markers: describing architecture for something nonexistent
239
+ fab = ["watermark", "水印", "sticker", "三层", "three-layer", "ArkTS.*C\\+\\+", "渲染引擎",
240
+ "HmcRenderEngine", "HmcEditorAsset", "WatermarkPicInfo", "跨层协作"]
241
+ r["fab_score"] = sum(1 for m in fab if re.search(m, text, re.I))
242
+ # Detect pattern type
243
+ if "不存在" in text or "not exist" in text.lower():
244
+ if r["fab_score"] >= 5:
245
+ r["pattern"] = "yes_and" # "doesn't exist, BUT here's how it works..."
246
+ elif r["fab_score"] >= 2:
247
+ r["pattern"] = "partial"
248
+ else:
249
+ r["pattern"] = "honest_denial"
250
+ else:
251
+ r["pattern"] = "pure_fabrication" if r["fab_score"] >= 3 else "none"
252
+ r["hallucinated"] = r["fab_score"] >= 3 and r["module_mentioned"]
253
+ # What real code was explored (wrongly attributed)
254
+ r["real_code_explored"] = list(set(stats.files_read))[:10]
255
+ qs = ["seems", "might", "may be", "possibly", "perhaps", "appears", "好像", "可能", "似乎"]
256
+ r["qualifiers"] = [q for q in qs if q in text.lower()]
257
+ return r
258
+
259
+
260
+ def collect_share_coverage(results_dir: Path) -> dict:
261
+ r = {"exp": "3-2", "has_results": False, "tool_calls": 0, "files_explored": 0,
262
+ "found": [], "missed": [], "coverage": "0/6"}
263
+ stream = results_dir / "3-2" / "stream_output.jsonl"
264
+ if not stream.exists(): return r
265
+ r["has_results"] = True
266
+ agent = "?"
267
+ rj = results_dir / "3-2" / "results.json"
268
+ if rj.exists():
269
+ agent = json.loads(rj.read_text(encoding="utf-8")).get("agent", "?")
270
+ stats = _stream_stats(stream, agent)
271
+ all_files = set(stats.files_read) | set(stats.files_edited) | set(stats.files_grepped)
272
+ text = "\n".join(stats.text_lines)
273
+ r["tool_calls"] = stats.tool_calls
274
+ r["files_explored"] = len(all_files)
275
+ targets = ["KnockShareUtil", "ShareUrlUtil", "SwingShareUtil",
276
+ "harmonyShare", "systemShare", "ShareBrowserCustomDialog"]
277
+ for name in targets:
278
+ matched = [f for f in all_files if name in f]
279
+ if not matched and name in text:
280
+ matched = [name]
281
+ if matched: r["found"].append({"name": name, "path": matched[0]})
282
+ else: r["missed"].append(name)
283
+ r["coverage"] = f"{len(r['found'])}/{len(targets)}"
284
+ r["coverage_pct"] = len(r["found"]) / len(targets) * 100
285
+ return r
286
+
287
+
288
+ def collect_cross_layer(results_dir: Path) -> dict:
289
+ r = {"exp": "2", "has_results": False, "tool_calls": 0, "arkts": 0, "napi": 0,
290
+ "cpp": 0, "cmake": 0, "layers_penetrated": 0, "max_turns_hit": False}
291
+ stream = results_dir / "2" / "stream_output.jsonl"
292
+ if not stream.exists(): return r
293
+ r["has_results"] = True
294
+ agent = "?"
295
+ rj = results_dir / "2" / "results.json"
296
+ if rj.exists():
297
+ agent = json.loads(rj.read_text(encoding="utf-8")).get("agent", "?")
298
+ stats = _stream_stats(stream, agent)
299
+ all_files = set(stats.files_read) | set(stats.files_edited)
300
+ r["tool_calls"] = stats.tool_calls
301
+ r["max_turns_hit"] = stats.max_turns_hit
302
+ r["arkts"] = len([f for f in all_files if f.endswith(".ets")])
303
+ r["napi"] = len([f for f in all_files if f.endswith(".ts") and "native" in f.lower()])
304
+ r["cpp"] = len([f for f in all_files if f.endswith((".cpp", ".h", ".hpp"))])
305
+ r["cmake"] = len([f for f in all_files if "CMakeLists" in f])
306
+ r["layers_penetrated"] = sum([r["arkts"] > 0, r["napi"] > 0, r["cpp"] > 0, r["cmake"] > 0])
307
+ return r
308
+
309
+
310
+ def collect_over_exploration(results_dir: Path) -> dict:
311
+ r = {"exp": "3-3", "has_results": False, "tool_calls": 0, "files_explored": 0,
312
+ "utils_files": 0, "target_hit": False, "efficiency_pct": 0}
313
+ stream = results_dir / "3-3" / "stream_output.jsonl"
314
+ if not stream.exists(): return r
315
+ r["has_results"] = True
316
+ agent = "?"
317
+ rj = results_dir / "3-3" / "results.json"
318
+ if rj.exists():
319
+ agent = json.loads(rj.read_text(encoding="utf-8")).get("agent", "?")
320
+ stats = _stream_stats(stream, agent)
321
+ all_files = set(stats.files_read)
322
+ r["tool_calls"] = stats.tool_calls
323
+ r["files_explored"] = len(all_files)
324
+ util_files = sorted([f for f in all_files if "Util" in f and f.endswith(".ets")])
325
+ r["utils_files"] = len(util_files)
326
+ r["utils_list"] = [Path(f).name for f in util_files[:15]]
327
+ r["target_hit"] = any("ImageUtil" in f for f in all_files)
328
+ r["efficiency_pct"] = round(1 / max(r["utils_files"], 1) * 100)
329
+ return r
330
+
331
+
332
+ def collect_multilang(results_dir: Path) -> dict:
333
+ r = {"exp": "4-1", "has_results": False, "count": 0, "has_zh": False, "has_base": False}
334
+ stream = results_dir / "4-1" / "stream_output.jsonl"
335
+ if not stream.exists(): return r
336
+ r["has_results"] = True
337
+ agent = "?"
338
+ rj = results_dir / "4-1" / "results.json"
339
+ if rj.exists():
340
+ agent = json.loads(rj.read_text(encoding="utf-8")).get("agent", "?")
341
+ stats = _stream_stats(stream, agent)
342
+ all_files = set(stats.files_read) | set(stats.files_edited)
343
+ langs = sorted([f for f in all_files if "string.json" in f])
344
+ r["count"] = len(langs)
345
+ r["has_base"] = any("base/element" in f for f in langs)
346
+ r["has_zh"] = any("zh_CN" in f for f in langs)
347
+ return r
348
+
349
+
350
+ def collect_dependency_knowledge(results_dir: Path) -> dict:
351
+ r = {"exp": "4-3", "has_results": False, "oh_package": False, "ohpm": False,
352
+ "build_gn_wrong": False, "score": 0}
353
+ stream = results_dir / "4-3" / "stream_output.jsonl"
354
+ if not stream.exists(): return r
355
+ r["has_results"] = True
356
+ agent = "?"
357
+ rj = results_dir / "4-3" / "results.json"
358
+ if rj.exists():
359
+ agent = json.loads(rj.read_text(encoding="utf-8")).get("agent", "?")
360
+ stats = _stream_stats(stream, agent)
361
+ all_files = set(stats.files_read) | set(stats.files_edited)
362
+ text = "\n".join(stats.text_lines)
363
+ r["oh_package"] = "oh-package" in text or any("oh-package" in f for f in all_files)
364
+ r["ohpm"] = "ohpm" in text
365
+ r["build_gn_wrong"] = "BUILD.gn" in text or any("BUILD.gn" in f for f in all_files)
366
+ r["score"] = sum([r["oh_package"], r["ohpm"], not r["build_gn_wrong"]])
367
+ return r
368
+
369
+
370
+ def collect_memory_curve(results_dir: Path) -> dict:
371
+ r = {"exp": "5", "has_results": False, "session_id": "", "rounds": [],
372
+ "decay_pct": 0, "decay_round": 0}
373
+ sj = results_dir / "5" / "session_results.json"
374
+ if not sj.exists(): return r
375
+ r["has_results"] = True
376
+ d = json.loads(sj.read_text(encoding="utf-8"))
377
+ agent = d.get("agent", "?")
378
+ r["session_id"] = d.get("session_id", "")[:16]
379
+ round_streams = sorted((results_dir / "5").glob("round_*/stream_output.jsonl"))
380
+ if round_streams:
381
+ for i, stream in enumerate(round_streams, 1):
382
+ stats = _stream_stats(stream, agent)
383
+ r["rounds"].append(dict(round=i, tool_calls=stats.tool_calls,
384
+ files_read=len(set(stats.files_read))))
385
+ else:
386
+ for rd in d.get("memory_retention_curve", []):
387
+ r["rounds"].append(dict(round=rd["round"], tool_calls=int(rd["tool_calls"]),
388
+ files_read=len(rd.get("files_read", []))))
389
+ if len(r["rounds"]) >= 2:
390
+ r1 = r["rounds"][0]["tool_calls"]
391
+ r2 = r["rounds"][1]["tool_calls"]
392
+ r["decay_pct"] = round((r1 - r2) / max(r1, 1) * 100)
393
+ r["decay_round"] = 1
394
+ prev = r1
395
+ for rd in r["rounds"][1:]:
396
+ drop = prev - rd["tool_calls"]
397
+ if drop > 0 and prev > 0:
398
+ pct = round(drop / prev * 100)
399
+ if pct > r["decay_pct"]:
400
+ r["decay_pct"] = pct
401
+ r["decay_round"] = rd["round"]
402
+ prev = rd["tool_calls"]
403
+ return r
404
+
405
+
406
+ def collect_aggregated(results_dir: Path) -> dict:
407
+ total_dur = 0; total_tc = 0; total_read = 0; total_in = 0; count = 0; hits = 0
408
+ total_hg_tc = 0; total_hg_effective = 0; total_hg_failed = 0
409
+ peak_agent_mb = 0.0; peak_hg_mb = 0.0; peak_combined_mb = 0.0
410
+ for exp_dir in sorted(results_dir.iterdir()):
411
+ if not exp_dir.is_dir(): continue
412
+ rj = exp_dir / "results.json"
413
+ if rj.exists():
414
+ d = json.loads(rj.read_text(encoding="utf-8"))
415
+ agent = d.get("agent", "?")
416
+ total_dur += d.get("duration_ms", 0)
417
+ tool_calls = d.get("tool_calls", 0)
418
+ files_read = len(d.get("files_read", []))
419
+ input_tokens = d.get("input_tokens", 0)
420
+ hg_tc = d.get("homegraph_tool_calls", 0)
421
+ total_hg_effective += d.get("homegraph_effective_calls", 0)
422
+ total_hg_failed += d.get("homegraph_failed_calls", 0)
423
+ mem = _memory_from_result(d)
424
+ peak_agent_mb = max(peak_agent_mb, mem["peak_rss_mb"])
425
+ peak_hg_mb = max(peak_hg_mb, mem["peak_homegraph_rss_mb"])
426
+ peak_combined_mb = max(peak_combined_mb, mem["peak_combined_rss_mb"])
427
+ stream = exp_dir / "stream_output.jsonl"
428
+ if stream.exists():
429
+ stats = _stream_stats(stream, agent)
430
+ if stats.tool_calls:
431
+ tool_calls = stats.tool_calls
432
+ files_read = len(set(stats.files_read))
433
+ if stats.total_input_tokens:
434
+ input_tokens = stats.total_input_tokens
435
+ if stats.homegraph_tool_calls:
436
+ hg_tc = stats.homegraph_tool_calls
437
+ total_tc += tool_calls
438
+ total_hg_tc += hg_tc
439
+ total_read += files_read
440
+ total_in += input_tokens
441
+ if d.get("max_turns_hit"): hits += 1
442
+ count += 1
443
+ sj = results_dir / "5" / "session_results.json"
444
+ if sj.exists():
445
+ d = json.loads(sj.read_text(encoding="utf-8"))
446
+ agent = d.get("agent", "?")
447
+ total_dur += d.get("total_duration_ms", 0)
448
+ session_tc = d.get("total_tool_calls", 0)
449
+ round_streams = sorted((results_dir / "5").glob("round_*/stream_output.jsonl"))
450
+ if round_streams:
451
+ session_tc = sum(_stream_stats(s, agent).tool_calls for s in round_streams)
452
+ total_tc += session_tc
453
+ count += 1
454
+ for rd in sorted((results_dir / "5").glob("round_*/round_results.json")):
455
+ rr = json.loads(rd.read_text(encoding="utf-8"))
456
+ mem = _memory_from_result(rr)
457
+ peak_agent_mb = max(peak_agent_mb, mem["peak_rss_mb"])
458
+ peak_hg_mb = max(peak_hg_mb, mem["peak_homegraph_rss_mb"])
459
+ peak_combined_mb = max(peak_combined_mb, mem["peak_combined_rss_mb"])
460
+ manifest = read_run_manifest(results_dir)
461
+ return dict(
462
+ experiment_count=count, total_dur_ms=total_dur, total_tool_calls=total_tc,
463
+ total_homegraph_tool_calls=total_hg_tc,
464
+ total_homegraph_effective_calls=total_hg_effective,
465
+ total_homegraph_failed_calls=total_hg_failed,
466
+ total_files_read=total_read, total_input_tokens=total_in, max_turns_hit_count=hits,
467
+ peak_rss_mb=round(peak_agent_mb, 1), peak_homegraph_rss_mb=round(peak_hg_mb, 1),
468
+ peak_combined_rss_mb=round(peak_combined_mb, 1),
469
+ homegraph_index_ms=manifest.get("homegraph_index_ms", 0),
470
+ homegraph_index_success=manifest.get("homegraph_index_success"),
471
+ homegraph_index_error=manifest.get("homegraph_index_error", ""),
472
+ homegraph_index_command=manifest.get("homegraph_index_command", ""),
473
+ homegraph_file_count=manifest.get("homegraph_file_count", 0),
474
+ homegraph_arkts_nodes=manifest.get("homegraph_arkts_nodes", 0),
475
+ )
476
+
477
+
478
+ # ═══════════════════════════════════════════════════════════
479
+ # Analysis & interpretation (derives insights from raw data)
480
+ # ═══════════════════════════════════════════════════════════
481
+
482
+ def analyze_gear_switching(summary: List[dict]) -> dict:
483
+ """Compare 1-1, 1-2, 1-3 to analyze exploration gear shifts."""
484
+ d = {r["id"]: r for r in summary}
485
+ e11 = d.get("1-1", {}); e12 = d.get("1-2", {}); e13 = d.get("1-3", {})
486
+ if not all([e11, e12, e13]): return {"valid": False}
487
+
488
+ tc_ratio = e13.get("tool_calls", 1) / max(e11.get("tool_calls", 1), 1)
489
+ dur_ratio = float(str(e13.get("duration_s", "1")).replace("s", "")) / max(float(str(e11.get("duration_s", "1")).replace("s", "")), 0.1)
490
+
491
+ # Check if 1-2 trap worked (few files read despite many tools)
492
+ trap_worked = e12.get("tool_calls", 0) > 10 and e12.get("files_read", 0) <= 1
493
+ # Check if 1-3 went deep (many files)
494
+ deep = e13.get("files_read", 0) >= 5
495
+
496
+ return dict(valid=True,
497
+ zero_gap_tools=e11.get("tool_calls", 0), zero_gap_dur=e11.get("duration_s", "?"),
498
+ weak_gap_tools=e12.get("tool_calls", 0), weak_gap_dur=e12.get("duration_s", "?"),
499
+ strong_gap_tools=e13.get("tool_calls", 0), strong_gap_dur=e13.get("duration_s", "?"),
500
+ tool_span=f"{e11.get('tool_calls', 0)} → {e13.get('tool_calls', 0)} ({tc_ratio:.0f}x)",
501
+ dur_span=f"{e11.get('duration_s', '?')} → {e13.get('duration_s', '?')} ({dur_ratio:.0f}x)",
502
+ trap_worked=trap_worked, deep_confirmed=deep,
503
+ rating=_score_to_stars(5 if tc_ratio >= 10 and deep else (4 if tc_ratio >= 5 else 3)))
504
+
505
+
506
+ def analyze_hallucination_detail(h: dict, summary: List[dict]) -> dict:
507
+ """Deep analysis of hallucination pattern."""
508
+ if not h.get("has_results"): return {"valid": False}
509
+ pattern_labels = {
510
+ "yes_and": "「Yes, and...」型 — 先承认不存在,再编造替代解释",
511
+ "pure_fabrication": "纯编造型 — 直接描述不存在的内容",
512
+ "partial": "部分编造 — 混合真实代码和虚假结论",
513
+ "honest_denial": "诚实否认 — 明确告知未找到,无编造",
514
+ "none": "无编造",
515
+ }
516
+ e11 = next((r for r in summary if r["id"] == "1-1"), {})
517
+ tools_vs_baseline = h["tool_calls"] / max(e11.get("tool_calls", 1), 1)
518
+ return dict(valid=True,
519
+ pattern=h["pattern"], pattern_label=pattern_labels.get(h["pattern"], "未知"),
520
+ fab_score=h["fab_score"],
521
+ tools_wasted=h["tool_calls"], files_wasted=h["files_explored"],
522
+ tools_vs_baseline=f"{tools_vs_baseline:.0f}x",
523
+ real_code_explored=h["real_code_explored"][:5],
524
+ risk_level="🔴 高" if h["fab_score"] >= 5 else ("🟡 中" if h["fab_score"] >= 3 else "🟢 低"),
525
+ rating=_score_to_stars(1 if h["hallucinated"] else 5))
526
+
527
+
528
+ def analyze_memory_decay_detail(mc: dict) -> dict:
529
+ if not mc.get("has_results") or len(mc["rounds"]) < 2: return {"valid": False}
530
+ rounds = mc["rounds"]
531
+ r1_tc = rounds[0]["tool_calls"]
532
+ r_last_tc = rounds[-1]["tool_calls"]
533
+ overall_decay = round((r1_tc - r_last_tc) / max(r1_tc, 1) * 100)
534
+ # Check if any later round re-explored (tools went up again)
535
+ re_explored = any(i > 1 and rounds[i]["tool_calls"] > rounds[i-1]["tool_calls"]
536
+ for i in range(1, len(rounds)))
537
+ return dict(valid=True,
538
+ r1_tools=r1_tc, r_last_tools=r_last_tc,
539
+ overall_decay_pct=overall_decay,
540
+ decay_inflection_round=mc["decay_round"],
541
+ decay_inflection_pct=mc["decay_pct"],
542
+ re_explored=re_explored,
543
+ rating=_score_to_stars(5 if overall_decay >= 70 and not re_explored
544
+ else (4 if overall_decay >= 50 else 3)))
545
+
546
+
547
+ def compute_scores(data: dict) -> dict:
548
+ """Compute final scorecard across 7 dimensions."""
549
+ s = {}
550
+ # 1. Gear switching
551
+ gs = data.get("gear_analysis", {})
552
+ s["探索档位切换"] = 5 if gs.get("tool_span", "1x").endswith("x") and int(gs.get("tool_span", "1x").split("x")[0]) >= 10 else (4 if gs.get("deep_confirmed") else 3)
553
+ # 2. Cross-layer
554
+ cl = data["cross_layer"]
555
+ s["跨层穿透"] = min(cl["layers_penetrated"], 4) if cl["layers_penetrated"] >= 2 else 2
556
+ # 3. Hallucination risk (inverted: high hallucination = low score)
557
+ h = data.get("hallucination_detail", {})
558
+ s["幻觉风险"] = 1 if h.get("fab_score", 0) >= 5 else (2 if h.get("fab_score", 0) >= 3 else 5)
559
+ # 4. Omission risk
560
+ sh = data["share"]
561
+ s["遗漏风险"] = 5 if sh.get("coverage_pct", 0) == 100 else (3 if sh.get("coverage_pct", 0) >= 50 else 1)
562
+ # 5. Exploration efficiency
563
+ o = data["over_exploration"]
564
+ s["探索效率"] = 5 if o.get("efficiency_pct", 0) >= 50 else (4 if o.get("efficiency_pct", 0) >= 25 else 3)
565
+ # 6. Non-code assets
566
+ dk = data["dependency"]
567
+ ml = data["multilang"]
568
+ asset_score = dk.get("score", 0) + (1 if ml.get("has_zh") else 0)
569
+ s["非代码资产"] = min(asset_score + 1, 5)
570
+ # 7. Session memory
571
+ md = data.get("memory_detail", {})
572
+ s["会话记忆"] = 5 if md.get("overall_decay_pct", 0) >= 70 else (4 if md.get("overall_decay_pct", 0) >= 40 else 3)
573
+ return s
574
+
575
+
576
+ def _score_to_stars(n: int) -> str:
577
+ return "⭐" * n + "☆" * (5 - n)
578
+
579
+
580
+ # ═══════════════════════════════════════════════════════════
581
+ # Markdown report builder
582
+ # ═══════════════════════════════════════════════════════════
583
+
584
+ def build_md(results_dir: Path, data: dict) -> str:
585
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
586
+ ag = data["aggregated"]
587
+ n = max(ag["experiment_count"], 1)
588
+ gs = data.get("gear_analysis", {})
589
+ hd = data.get("hallucination_detail", {})
590
+ md = data.get("memory_detail", {})
591
+ scores = data.get("scores", {})
592
+
593
+ # ── Header & Executive Summary ──
594
+ agent_label = data["summary"][0].get("agent", "Unknown") if data["summary"] else "Unknown"
595
+ model_label = resolve_primary_model(data["summary"])
596
+ md_text = f"""# Agent Exploration 实验分析报告
597
+
598
+ > **生成时间**: {now} | **Agent**: {agent_label} | **模型**: {model_label}
599
+ > **仓库**: OpenHarmony Photos | **实验数**: {ag['experiment_count']} | **总耗时**: {ag['total_dur_ms'] / 1000:.1f}s ({ag['total_dur_ms'] / 60000:.1f}min)
600
+ > **总工具调用**: {ag['total_tool_calls']} | **总输入 Token**: {ag['total_input_tokens']:,}
601
+
602
+ ---
603
+
604
+ ## 总评分
605
+
606
+ | 维度 | 评分 | 说明 |
607
+ |------|:--:|------|
608
+ | 探索档位切换 | {_score_to_stars(scores.get('探索档位切换', 0))} | {gs.get('tool_span', 'N/A')} 跨度,{'深度探索已验证' if gs.get('deep_confirmed') else '待确认'} |
609
+ | 跨层穿透 | {_score_to_stars(scores.get('跨层穿透', 0))} | {data['cross_layer']['layers_penetrated']}/4 层穿透 |
610
+ | 幻觉风险 | {_score_to_stars(scores.get('幻觉风险', 0))} | {hd.get('pattern_label', 'N/A')},编造评分 {hd.get('fab_score', 0)}/11 |
611
+ | 遗漏风险 | {_score_to_stars(scores.get('遗漏风险', 0))} | 覆盖率 {data['share'].get('coverage', 'N/A')} |
612
+ | 探索效率 | {_score_to_stars(scores.get('探索效率', 0))} | 过度探索率 {data['over_exploration'].get('files_explored', 0)} 文件 |
613
+ | 非代码资产 | {_score_to_stars(scores.get('非代码资产', 0))} | oh-package/ohpm/多语言感知 |
614
+ | 会话记忆 | {_score_to_stars(scores.get('会话记忆', 0))} | 衰减 {md.get('overall_decay_pct', 0)}%,{'无重探索' if not md.get('re_explored', True) else '有部分重探索'} |
615
+
616
+ > **核心结论**: {agent_label} 在探索任务中表现出可预测的档位切换行为、{
617
+ '完整' if data['cross_layer']['layers_penetrated'] >= 3 else '部分'}的跨层穿透能力和{
618
+ '优秀' if md.get('overall_decay_pct', 0) >= 70 else '一般'}的上下文记忆,但{
619
+ '「Yes, and...」型幻觉是最大风险' if hd.get('pattern') == 'yes_and' else '幻觉风险需关注'}——{
620
+ '用真实代码片段包装虚假结论,极难通过自动化检测发现' if hd.get('fab_score', 0) >= 5 else ''}
621
+
622
+ ---
623
+
624
+ ## 数据总览
625
+
626
+ | Exp | Title | Model | Duration | Tool Calls | Files Read | Files Edited | Input Tokens | Max Turns? |
627
+ |-----|-------|-------|----------|------------|------------|--------------|--------------|------------|
628
+ """
629
+ for r in data["summary"]:
630
+ it = r["input_tokens"]
631
+ it_str = f"{it // 1000:,}k" if isinstance(it, int) else str(it)
632
+ hit = "⚠️ YES" if r["max_turns_hit"] else ""
633
+ md_text += f"| {r['id']} | {r['title'][:40]} | {format_model_display(r.get('model', '?'))} | {r['duration_s']} | {r['tool_calls']} | {r['files_read']} | {r['files_edited']} | {it_str} | {hit} |\n"
634
+
635
+ # ── Aggregated metrics ──
636
+ md_text += f"""
637
+ ---
638
+
639
+ ## 汇总指标
640
+
641
+ | 指标 | 数值 |
642
+ |------|------|
643
+ | 实验总数 | {ag['experiment_count']} |
644
+ | 使用模型 | {model_label} |
645
+ | 总耗时 | {ag['total_dur_ms'] / 1000:.1f}s ({ag['total_dur_ms'] / 60000:.1f}min) |
646
+ | 总工具调用 | {ag['total_tool_calls']} |
647
+ | 总输入 Token | {ag['total_input_tokens']:,} |
648
+ | Max turns 命中 | {ag['max_turns_hit_count']} 个实验 |
649
+ | Agent 进程峰值内存 | {_format_mb(ag.get('peak_rss_mb'))} |
650
+ | HomeGraph 进程峰值内存 | {_format_mb(ag.get('peak_homegraph_rss_mb'))} |
651
+ | 合计峰值内存 | {_format_mb(ag.get('peak_combined_rss_mb'))} |
652
+ | HomeGraph 索引耗时 | {_format_index_ms(ag.get('homegraph_index_ms', 0))} |
653
+ | 平均耗时/实验 | {ag['total_dur_ms'] / 1000 / n:.1f}s |
654
+ | 平均工具调用/实验 | {ag['total_tool_calls'] / n:.1f} |
655
+ | 平均输入 Token/实验 | {ag['total_input_tokens'] // n:,} |
656
+
657
+ ---
658
+
659
+ ## 一、档位切换模型分析
660
+
661
+ ### 量化对比
662
+
663
+ | 实验 | 工具调用 | 耗时 | 文件探索 | 档位 |
664
+ |------|---------|------|---------|------|
665
+ | 1-1 零缺口 | **{gs.get('zero_gap_tools', '?')}** | {gs.get('zero_gap_dur', '?')} | 1 | 快速检索 |
666
+ | 1-2 弱缺口 | **{gs.get('weak_gap_tools', '?')}** | {gs.get('weak_gap_dur', '?')} | 1 | {'⚠️ 中深度(trap 生效:在 common 模块反复搜索)' if gs.get('trap_worked') else '中深度'} |
667
+ | 1-3 强缺口 | **{gs.get('strong_gap_tools', '?')}** | {gs.get('strong_gap_dur', '?')} | {data['summary'][2]['files_read'] if len(data['summary']) > 2 else '?'} | {'✓ 深度探索' if gs.get('deep_confirmed') else '中深度'} |
668
+
669
+ **工具调用跨度**: {gs.get('tool_span', 'N/A')}
670
+ **耗时跨度**: {gs.get('dur_span', 'N/A')}
671
+
672
+ ### 结论
673
+
674
+ 信息缺口大小与探索深度呈正比。{'1-2 的 trap(目标在 feature/thirdselect 而非 common)生效——Agent 在被误导的模块中消耗了额外探索预算。' if gs.get('trap_worked') else ''}
675
+
676
+ ---
677
+
678
+ ## 二、跨层穿透能力
679
+
680
+ ### 层次覆盖
681
+
682
+ | 层 | 文件数 | 状态 |
683
+ |----|--------|------|
684
+ | ArkTS (.ets) | {data['cross_layer']['arkts']} | {'✓' if data['cross_layer']['arkts'] > 0 else '✗'} |
685
+ | NAPI (native.ts) | {data['cross_layer']['napi']} | {'✓ 找到唯一 bridge' if data['cross_layer']['napi'] > 0 else '✗'} |
686
+ | C++ (.cpp/.h) | {data['cross_layer']['cpp']} | {'✓ 深入引擎层' if data['cross_layer']['cpp'] > 0 else '✗'} |
687
+ | CMake | {data['cross_layer']['cmake']} | {'✓' if data['cross_layer']['cmake'] > 0 else '✗'} |
688
+
689
+ **穿透层数**: {data['cross_layer']['layers_penetrated']}/4
690
+ **工具调用**: {data['cross_layer']['tool_calls']} | Max turns: {'⚠️ 命中' if data['cross_layer']['max_turns_hit'] else '✓ 未触发'}
691
+ """
692
+
693
+ # ── Hallucination ──
694
+ h = data["hallucination"]
695
+ if h["has_results"]:
696
+ risk_emoji = "🔴" if hd.get("risk_level", "").startswith("🔴") else ("🟡" if hd.get("risk_level", "").startswith("🟡") else "🟢")
697
+ md_text += f"""
698
+ ---
699
+
700
+ ## 三、幻觉分析 {risk_emoji}
701
+
702
+ ### 检测结果
703
+
704
+ | 指标 | 数值 |
705
+ |------|------|
706
+ | 工具调用(浪费) | **{h['tool_calls']}**(是基线 1-1 的 {hd.get('tools_vs_baseline', '?')} 倍) |
707
+ | 文件探索(浪费) | **{h['files_explored']}** |
708
+ | 编造评分 | **{h['fab_score']}/11** |
709
+ | 幻觉模式 | **{hd.get('pattern_label', 'N/A')}** |
710
+ | 风险等级 | {hd.get('risk_level', 'N/A')} |
711
+ | 不确定性表达 | {', '.join(h['qualifiers']) if h.get('qualifiers') else '无'} |
712
+
713
+ ### 模式分析
714
+
715
+ """
716
+ if hd.get("pattern") == "yes_and":
717
+ md_text += f"""**「Yes, and...」型幻觉** — 最危险的谎言模式:
718
+
719
+ 1. Agent 搜索 `PhotoWatermarkEngine` → 未找到
720
+ 2. 搜索 `watermark` / `水印` → **找到真实存在的编辑引擎水印 sticker 代码**
721
+ 3. 将编辑引擎的代码**错误归因**为 `PhotoWatermarkEngine` 模块
722
+ 4. 输出包含真实文件路径、枚举值、C++ 数据结构的「完整架构分析」
723
+
724
+ **关键风险**: 这不是纯粹的编造,而是**对真实代码的错误语义解释**。只检查文件路径无法发现——需要人工验证输出的语义正确性。
725
+
726
+ **被错误引用的真实代码**(Agent 将这些代码归属到不存在的模块下):
727
+ """
728
+ for f in hd.get("real_code_explored", [])[:5]:
729
+ md_text += f"- `{f}`\n"
730
+ elif hd.get("pattern") == "honest_denial":
731
+ md_text += "Agent 明确告知未找到目标模块,无编造行为。✓\n"
732
+ else:
733
+ md_text += f"幻觉模式: {hd.get('pattern', 'unknown')}\n"
734
+
735
+ # ── Share coverage ──
736
+ s = data["share"]
737
+ if s["has_results"]:
738
+ md_text += f"""
739
+ ---
740
+
741
+ ## 四、遗漏检测
742
+
743
+ | 指标 | 数值 |
744
+ |------|------|
745
+ | 工具调用 | {s['tool_calls']} |
746
+ | 文件探索 | {s['files_explored']} |
747
+ | 覆盖率 | **{s['coverage']}** ({s.get('coverage_pct', 0):.0f}%) |
748
+ | 搜索策略 | {'地毯式(' + str(s['tool_calls']) + ' tools)' if s['tool_calls'] > 100 else ('标准(' + str(s['tool_calls']) + ' tools)')} |
749
+
750
+ """
751
+ if s["found"]:
752
+ md_text += "**命中的文件**:\n"
753
+ for f in s["found"]:
754
+ md_text += f"- ✓ `{f['name']}`\n"
755
+ if s["missed"]:
756
+ md_text += "\n**遗漏的文件**:\n"
757
+ for m in s["missed"]:
758
+ md_text += f"- ✗ `{m}` — **命名陷阱生效**\n"
759
+ if not s["missed"]:
760
+ md_text += "\n本次实验**零遗漏**,包括命名陷阱 `KnockShareUtil.ets` 也被正确找到。Agent 采用地毯式搜索策略(grep + 目录遍历 + 文件读取),覆盖率优秀。\n"
761
+
762
+ # ── Over-exploration ──
763
+ o = data["over_exploration"]
764
+ if o["has_results"]:
765
+ md_text += f"""
766
+ ---
767
+
768
+ ## 五、过度探索分析
769
+
770
+ | 指标 | 数值 |
771
+ |------|------|
772
+ | 工具调用 | {o['tool_calls']} |
773
+ | 总文件探索 | {o['files_explored']} |
774
+ | Utils 文件探索 | {o['utils_files']}(共 72 个可用) |
775
+ | 命中最优目标 (ImageUtil.ets) | {'✓' if o['target_hit'] else '✗'} |
776
+ | 探索效率 | {o.get('efficiency_pct', 0)}% |
777
+
778
+ **判定**: {'**无过度探索** — Agent 精确定位到最优文件,仅做一次对比阅读(ImageSizeUtil.ets)后即做出决策。' if o.get('efficiency_pct', 0) >= 50 else ('**轻微探索** — 读取了 ' + str(o['utils_files']) + ' 个 Utils 文件后定位目标。')}
779
+
780
+ """
781
+
782
+ # ── Non-code assets ──
783
+ ml = data["multilang"]
784
+ dk = data["dependency"]
785
+ md_text += f"""
786
+ ---
787
+
788
+ ## 六、非代码资产感知
789
+
790
+ ### 6.1 资源配置 (Exp 4-1)
791
+
792
+ """
793
+ if ml["has_results"]:
794
+ md_text += f"""| 指标 | 数值 |
795
+ |------|------|
796
+ | 命中的 string.json | {ml['count']} 个 |
797
+ | 包含 base 语言 | {'✓' if ml['has_base'] else '✗'} |
798
+ | 包含中文 locale | {'✓' if ml['has_zh'] else '✗'} |
799
+ """
800
+ if ml.get("has_zh"):
801
+ md_text += 'Agent 识别了中文 `zh_CN` 资源文件的差异(值为「图库」而非「Gallery」),做了正确的差异化处理。✓\n'
802
+
803
+ md_text += f"""
804
+ ### 6.2 构建系统 (Exp 4-3)
805
+
806
+ | 检查项 | 结果 |
807
+ |--------|------|
808
+ | oh-package.json5 识别 | {'✓' if dk['oh_package'] else '✗'} |
809
+ | ohpm 包管理器识别 | {'✓' if dk['ohpm'] else '✗'} |
810
+ | BUILD.gn 错误引用 | {'⚠️ 提及了不存在的构建系统' if dk['build_gn_wrong'] else '✓ 未错误引用'} |
811
+ | 综合评分 | {dk.get('score', 0)}/3 |
812
+ """
813
+
814
+ # ── Memory curve ──
815
+ mc = data["memory"]
816
+ if md.get("valid") and mc.get("has_results"):
817
+ md_text += f"""
818
+ ---
819
+
820
+ ## 七、会话持久性
821
+
822
+ ### 记忆保留曲线
823
+
824
+ | Round | Tool Calls | Files Read | vs Previous | 行为 |
825
+ |-------|------------|------------|-------------|------|
826
+ """
827
+ prev = mc["rounds"][0]["tool_calls"] if mc["rounds"] else 1
828
+ for i, r in enumerate(mc["rounds"]):
829
+ tc = r["tool_calls"]
830
+ delta_pct = round((tc - prev) / max(prev, 1) * 100)
831
+ if i == 0:
832
+ behavior = "建立上下文"
833
+ elif delta_pct < -50:
834
+ behavior = f"↓ 复用上下文({-delta_pct}% 减少)"
835
+ elif delta_pct > 20:
836
+ behavior = f"↑ 跨模块微增"
837
+ else:
838
+ behavior = "→ 记忆保留"
839
+ bar = "█" * (tc // 3)
840
+ md_text += f"| {r['round']} | {tc} | {r['files_read']} | {delta_pct:+d}% | {bar} {behavior} |\n"
841
+ prev = tc
842
+
843
+ md_text += f"""
844
+ ### 衰减分析
845
+
846
+ | 指标 | 数值 |
847
+ |------|------|
848
+ | 初始探索量 (R1) | {md['r1_tools']} tools |
849
+ | 最终探索量 (R5) | {md['r_last_tools']} tools |
850
+ | 总体衰减率 | **{md['overall_decay_pct']}%** |
851
+ | 最大衰减拐点 | Round {md['decay_inflection_round']} → Round {md['decay_inflection_round'] + 1} ({md['decay_inflection_pct']}% 下降) |
852
+ | 重探索行为 | {'⚠️ 检测到重探索(后续轮工具调用反升)' if md.get('re_explored') else '✓ 无重探索,记忆持续保留'} |
853
+
854
+ **判定**: 上下文建立后,后续任务探索量下降 **{md['overall_decay_pct']}%**。{'跨模块任务(Round 3)有小幅回升,但回到同模块任务后(Round 4-5)继续维持低探索量。Session 持久性在 5 轮内表现优秀。' if md['overall_decay_pct'] >= 70 else ''}
855
+ """
856
+
857
+ # ── Footer ──
858
+ md_text += f"""
859
+ ---
860
+
861
+ *报告由 `analyze.py` 自动生成 | 原始数据: `{results_dir}`*
862
+ """
863
+ return md_text
864
+
865
+
866
+ # ═══════════════════════════════════════════════════════════
867
+ # Terminal output
868
+ # ═══════════════════════════════════════════════════════════
869
+
870
+ def print_summary(rows: List[dict]):
871
+ agent_name = rows[0].get("agent", "?") if rows else "?"
872
+ hdr = f"{'Exp':<6} | {'Title':<34} | {'Dur':>6} | {'Tools':>6} | {'Read':>5} | {'Edit':>5} | {'InputTok':>9} | {'Turns?':>7}"
873
+ print(f"\n Agent: {agent_name}\n" + hdr); print("-" * len(hdr))
874
+ for r in rows:
875
+ it = r["input_tokens"]; it_str = f"{it // 1000}k" if isinstance(it, int) else str(it)
876
+ hit = "⚠️" if r["max_turns_hit"] else ""
877
+ print(f"{r['id']:<6} | {r['title'][:34]:<34} | {str(r['duration_s']):>6} | "
878
+ f"{str(r['tool_calls']):>6} | {str(r['files_read']):>5} | "
879
+ f"{str(r['files_edited']):>5} | {it_str:>9} | {hit:>7}")
880
+
881
+
882
+ def print_all(results_dir: Path, data: dict):
883
+ header("Agent Exploration — Results Analysis")
884
+ print_summary(data["summary"])
885
+ gs = data.get("gear_analysis", {})
886
+ if gs.get("valid"):
887
+ header("档位切换 (Exp 1-1 → 1-3)")
888
+ print(f" Tool span : {gs['tool_span']} | Dur span: {gs['dur_span']}")
889
+ print(f" Trap 1-2 : {'✓生效' if gs.get('trap_worked') else '✗未生效'}")
890
+ print(f" Deep 1-3 : {'✓深度探索' if gs.get('deep_confirmed') else '✗未达深度'}")
891
+ hd = data.get("hallucination_detail", {})
892
+ if hd.get("valid"):
893
+ header(f"幻觉检测 (Exp 3-1) — {hd.get('risk_level', 'N/A')}")
894
+ print(f" Pattern : {hd.get('pattern_label', 'N/A')}")
895
+ print(f" Score : {hd.get('fab_score', 0)}/11 | Tools wasted: {hd.get('tools_wasted', 0)}")
896
+ s = data["share"]
897
+ if s["has_results"]:
898
+ header(f"分享覆盖 (Exp 3-2) — {s.get('coverage', '?')}")
899
+ if s.get("missed"): print(f" Missed : {s['missed']}")
900
+ else: print(" All 6/6 found — 零遗漏")
901
+ cl = data["cross_layer"]
902
+ if cl["has_results"]:
903
+ header(f"跨层穿透 (Exp 2) — {cl['layers_penetrated']}/4 层")
904
+ print(f" ArkTS:{cl['arkts']} NAPI:{cl['napi']} C++:{cl['cpp']} CMake:{cl['cmake']}")
905
+ o = data["over_exploration"]
906
+ if o["has_results"]:
907
+ header(f"过度探索 (Exp 3-3) — 效率 {o.get('efficiency_pct', 0)}%")
908
+ print(f" Utils explored: {o['utils_files']} | Target: {'✓' if o['target_hit'] else '✗'}")
909
+ mc = data.get("memory", {})
910
+ if mc.get("has_results"):
911
+ header(f"记忆衰减 (Exp 5) — {data.get('memory_detail', {}).get('overall_decay_pct', 0)}%")
912
+ for r in mc["rounds"]:
913
+ print(f" R{r['round']}: {r['tool_calls']} tools ({'█' * (r['tool_calls'] // 3)})")
914
+ header("总评分")
915
+ for dim, score in data.get("scores", {}).items():
916
+ print(f" {dim:<12} : {_score_to_stars(score)}")
917
+ ag = data["aggregated"]
918
+ print(f"\n Total: {ag['total_tool_calls']} tools | {ag['total_input_tokens']:,} tokens | {ag['total_dur_ms'] / 60000:.1f}min")
919
+
920
+
921
+ def _parse_duration_s(val) -> float:
922
+ """Parse duration_s field ('15.2', '163.7s', 15.2) to seconds."""
923
+ if isinstance(val, (int, float)):
924
+ return float(val)
925
+ s = str(val).strip().lower().rstrip("s")
926
+ try:
927
+ return float(s)
928
+ except ValueError:
929
+ return 0.0
930
+
931
+
932
+ def _pct_change(baseline: float, homegraph: float) -> str:
933
+ if baseline == 0:
934
+ return "—" if homegraph == 0 else "+∞"
935
+ pct = (homegraph - baseline) / baseline * 100
936
+ sign = "+" if pct > 0 else ""
937
+ return f"{sign}{pct:.0f}%"
938
+
939
+
940
+ def _delta_str(baseline: float, homegraph: float, unit: str = "") -> str:
941
+ d = homegraph - baseline
942
+ sign = "+" if d > 0 else ""
943
+ if unit == "s":
944
+ return f"{sign}{d:.1f}s"
945
+ if unit == "min":
946
+ return f"{sign}{d / 60:.1f}min"
947
+ if isinstance(baseline, int) or isinstance(homegraph, int):
948
+ return f"{sign}{int(d)}"
949
+ return f"{sign}{d:.1f}"
950
+
951
+
952
+ def _format_wall_duration(seconds: int) -> str:
953
+ return f"{seconds // 60}m {seconds % 60}s"
954
+
955
+
956
+ def gather_data(results_dir: Path) -> dict:
957
+ """Collect metrics and derived insights for one run directory."""
958
+ data = dict(
959
+ summary=collect_summary(results_dir),
960
+ hallucination=collect_hallucination(results_dir),
961
+ share=collect_share_coverage(results_dir),
962
+ cross_layer=collect_cross_layer(results_dir),
963
+ over_exploration=collect_over_exploration(results_dir),
964
+ multilang=collect_multilang(results_dir),
965
+ dependency=collect_dependency_knowledge(results_dir),
966
+ memory=collect_memory_curve(results_dir),
967
+ aggregated=collect_aggregated(results_dir),
968
+ )
969
+ data["gear_analysis"] = analyze_gear_switching(data["summary"])
970
+ data["hallucination_detail"] = analyze_hallucination_detail(data["hallucination"], data["summary"])
971
+ data["memory_detail"] = analyze_memory_decay_detail(data["memory"])
972
+ data["scores"] = compute_scores(data)
973
+ return data
974
+
975
+
976
+ def analyze_run(results_dir: Path, *, input_path: Optional[Path] = None,
977
+ verbose: bool = True) -> Path:
978
+ """Analyze one run and write analysis_report.md. Returns report path."""
979
+ results_dir = resolve_results_dir(Path(results_dir))
980
+ if not _has_experiment_outputs(results_dir):
981
+ raise ValueError(f"No experiment outputs under: {results_dir}")
982
+
983
+ input_path = Path(input_path) if input_path else results_dir
984
+ data = gather_data(results_dir)
985
+ if verbose:
986
+ print_all(results_dir, data)
987
+ report_path = resolve_report_path(input_path, results_dir)
988
+ report_path.write_text(build_md(results_dir, data), encoding="utf-8")
989
+ if verbose:
990
+ print(f"\n{GREEN}[ANALYZE]{NC} Report: {report_path}")
991
+ return report_path
992
+
993
+
994
+ def _format_index_ms(ms: int) -> str:
995
+ if not ms:
996
+ return "—(未记录或 baseline 组无此步骤)"
997
+ return f"{ms / 1000:.1f}s"
998
+
999
+
1000
+ def _overhead_s(wall_s: int, agent_ms: int, index_ms: int = 0) -> str:
1001
+ if not wall_s:
1002
+ return "—"
1003
+ overhead = wall_s - agent_ms / 1000 - index_ms / 1000
1004
+ return f"≈{max(overhead, 0):.0f}s"
1005
+
1006
+
1007
+ def _overhead_s(wall_s: int, agent_ms: int, index_ms: int = 0) -> str:
1008
+ if not wall_s:
1009
+ return "—"
1010
+ overhead = wall_s - agent_ms / 1000 - index_ms / 1000
1011
+ return f"≈{max(overhead, 0):.0f}s"
1012
+
1013
+
1014
+ def _homegraph_tool_counts(tool_names: list) -> dict:
1015
+ counts = {}
1016
+ for name in tool_names or []:
1017
+ if is_homegraph_tool(name):
1018
+ counts[name] = counts.get(name, 0) + 1
1019
+ return counts
1020
+
1021
+
1022
+ def _format_hg_tool_detail(hg_tools: dict) -> str:
1023
+ if not hg_tools:
1024
+ return "—"
1025
+ return ", ".join(f"`{name}` ×{n}" for name, n in sorted(hg_tools.items()))
1026
+
1027
+
1028
+ def _build_ab_validity_section(b_sum: dict, h_sum: dict, exp_ids: list,
1029
+ h_ag: dict) -> str:
1030
+ b_total = sum(r.get("homegraph_tool_calls", 0) for r in b_sum.values())
1031
+ h_total = sum(r.get("homegraph_tool_calls", 0) for r in h_sum.values())
1032
+ h_effective = sum(r.get("homegraph_effective_calls", 0) for r in h_sum.values())
1033
+ h_failed = sum(r.get("homegraph_failed_calls", 0) for r in h_sum.values())
1034
+ index_cmd = h_ag.get("homegraph_index_command", "index")
1035
+ index_ok = h_ag.get("homegraph_index_success")
1036
+ if index_ok is True:
1037
+ index_label = f"成功(`{index_cmd}`"
1038
+ if h_ag.get("homegraph_index_ms"):
1039
+ index_label += f", {h_ag['homegraph_index_ms'] / 1000:.1f}s"
1040
+ fc = h_ag.get("homegraph_file_count")
1041
+ if fc:
1042
+ index_label += f", {fc} files"
1043
+ index_label += ")"
1044
+ elif index_ok is False:
1045
+ index_label = f"失败(`{index_cmd}`)"
1046
+ err = h_ag.get("homegraph_index_error", "")
1047
+ if err:
1048
+ index_label += f":{err[:100]}"
1049
+ else:
1050
+ index_label = "—"
1051
+ md = f"""## A/B 有效性
1052
+
1053
+ **Baseline** HomeGraph 工具调用合计:**{b_total}**
1054
+
1055
+ **HomeGraph** HomeGraph 工具调用合计:**{h_total}**(有效 **{h_effective}** / 失败 **{h_failed}**)
1056
+
1057
+ **HomeGraph 索引**:**{index_label}**
1058
+
1059
+ | 实验 | Baseline HG | HomeGraph HG | HomeGraph 有效 | HomeGraph 失败 | HomeGraph 各工具(次数) |
1060
+ |------|------------|--------------|----------------|----------------|-------------------------|
1061
+ """
1062
+ for eid in exp_ids:
1063
+ br, hr = b_sum.get(eid, {}), h_sum.get(eid, {})
1064
+ b_n = br.get("homegraph_tool_calls", 0)
1065
+ h_n = hr.get("homegraph_tool_calls", 0)
1066
+ h_eff = hr.get("homegraph_effective_calls", 0)
1067
+ h_fail = hr.get("homegraph_failed_calls", 0)
1068
+ h_detail = _format_hg_tool_detail(hr.get("homegraph_tools", {}))
1069
+ md += f"| {eid} | {b_n} | {h_n} | {h_eff} | {h_fail} | {h_detail} |\n"
1070
+ return md
1071
+
1072
+
1073
+ _FIXED_EXPERIMENT_EXPECTATIONS = """## 附录:实验预期(普适分析)
1074
+
1075
+ > 本节为**固定参考**,与上方实测数据无关,用于解读档位切换实验的设计意图。
1076
+
1077
+ ### Exp 1-1 — 零缺口任务(改常量)
1078
+
1079
+ - **任务特征**:Prompt 已给出完整文件路径,只需定位一行常量并修改。
1080
+ - **耗时预期**:Baseline 通常更快 — 一次 `read` + 一次 `edit` 即可,HomeGraph 的 MCP 往返是额外开销。
1081
+ - **Token 预期**:两组接近;Baseline 上下文更小,HomeGraph 组可能因 explore 返回结构化源码而略高或略低。
1082
+ - **工具预期**:Baseline 2–4 次工具调用;HomeGraph 组若仍调用 explore,工具数可能相当或略多。
1083
+
1084
+ ### Exp 1-2 — 弱缺口任务(跨模块定位)
1085
+
1086
+ - **任务特征**:需找到 `ThirdSelectAlbumGridBase`,文件在 `feature/thirdselect` 而非 `common`(命名陷阱)。
1087
+ - **耗时预期**:不确定 — Baseline 可能多次 grep 试错;HomeGraph explore 若一次命中可更快,若 query 不准则相当。
1088
+ - **Token 预期**:HomeGraph 组有望更低 — 减少逐文件 read 的上下文累积。
1089
+ - **工具预期**:Baseline 工具调用可能较多(glob/grep/read 组合);HomeGraph 组 explore 成功时可显著减少 read 次数。
1090
+
1091
+ ### Exp 1-3 — 强缺口任务(跨文件分析)
1092
+
1093
+ - **任务特征**:梳理图片加载失败的处理逻辑,涉及多个组件与错误状态,纯分析不改代码。
1094
+ - **耗时预期**:HomeGraph 组通常更有优势 — 一次 explore 可串联调用链,减少盲目 grep/read。
1095
+ - **Token 预期**:HomeGraph 组应明显更低 — 避免大量文件全文进入上下文。
1096
+ - **工具预期**:Baseline 可能需要 10+ 次 read/grep;HomeGraph 组 1–3 次 explore + 少量补充 read 即可覆盖。
1097
+ """
1098
+
1099
+
1100
+ def build_compare_md(baseline_dir: Path, homegraph_dir: Path, baseline_data: dict,
1101
+ homegraph_data: dict, *, agent: str = "",
1102
+ baseline_wall_s: int = 0, homegraph_wall_s: int = 0) -> str:
1103
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
1104
+ b_ag = baseline_data["aggregated"]
1105
+ h_ag = homegraph_data["aggregated"]
1106
+ b_sum = {r["id"]: r for r in baseline_data["summary"]}
1107
+ h_sum = {r["id"]: r for r in homegraph_data["summary"]}
1108
+ exp_ids = sorted(set(b_sum) | set(h_sum))
1109
+
1110
+ agent_label = agent or baseline_data["summary"][0].get("agent", "?") if baseline_data["summary"] else "?"
1111
+
1112
+ md = f"""# HomeGraph A/B 对比报告
1113
+
1114
+ > **生成时间**: {now}
1115
+ > **Agent**: {agent_label}
1116
+ > **Baseline(无 HomeGraph)**: `{baseline_dir}`
1117
+ > **HomeGraph(有 MCP)**: `{homegraph_dir}`
1118
+
1119
+ ---
1120
+
1121
+ ## 汇总对比
1122
+
1123
+ | 指标 | Baseline | HomeGraph | 差值 | 相对变化 |
1124
+ |------|----------|-----------|------|----------|
1125
+ | 整组墙钟耗时 | {_format_wall_duration(baseline_wall_s) if baseline_wall_s else '—'} | {_format_wall_duration(homegraph_wall_s) if homegraph_wall_s else '—'} | {_delta_str(baseline_wall_s, homegraph_wall_s, 'min') if baseline_wall_s and homegraph_wall_s else '—'} | {_pct_change(baseline_wall_s, homegraph_wall_s) if baseline_wall_s and homegraph_wall_s else '—'} |
1126
+ | Agent 累计耗时 | {b_ag['total_dur_ms'] / 1000:.1f}s | {h_ag['total_dur_ms'] / 1000:.1f}s | {_delta_str(b_ag['total_dur_ms'] / 1000, h_ag['total_dur_ms'] / 1000, 's')} | {_pct_change(b_ag['total_dur_ms'] / 1000, h_ag['total_dur_ms'] / 1000)} |
1127
+ | 总工具调用 | {b_ag['total_tool_calls']} | {h_ag['total_tool_calls']} | {_delta_str(b_ag['total_tool_calls'], h_ag['total_tool_calls'])} | {_pct_change(b_ag['total_tool_calls'], h_ag['total_tool_calls'])} |
1128
+ | HomeGraph 工具调用 | {b_ag.get('total_homegraph_tool_calls', 0)} | {h_ag.get('total_homegraph_tool_calls', 0)} | {_delta_str(b_ag.get('total_homegraph_tool_calls', 0), h_ag.get('total_homegraph_tool_calls', 0))} | — |
1129
+ | 总读取文件数 | {b_ag['total_files_read']} | {h_ag['total_files_read']} | {_delta_str(b_ag['total_files_read'], h_ag['total_files_read'])} | {_pct_change(b_ag['total_files_read'], h_ag['total_files_read'])} |
1130
+ | 总 Input Token | {b_ag['total_input_tokens']:,} | {h_ag['total_input_tokens']:,} | {_delta_str(b_ag['total_input_tokens'], h_ag['total_input_tokens'])} | {_pct_change(b_ag['total_input_tokens'], h_ag['total_input_tokens'])} |
1131
+ | Max turns 命中 | {b_ag['max_turns_hit_count']} | {h_ag['max_turns_hit_count']} | {_delta_str(b_ag['max_turns_hit_count'], h_ag['max_turns_hit_count'])} | — |
1132
+ | Agent 进程峰值内存 | {_format_mb(b_ag.get('peak_rss_mb'))} | {_format_mb(h_ag.get('peak_rss_mb'))} | — | — |
1133
+ | HomeGraph 进程峰值内存 | {_format_mb(b_ag.get('peak_homegraph_rss_mb'))} | {_format_mb(h_ag.get('peak_homegraph_rss_mb'))} | — | — |
1134
+ | 合计峰值内存 | {_format_mb(b_ag.get('peak_combined_rss_mb'))} | {_format_mb(h_ag.get('peak_combined_rss_mb'))} | — | — |
1135
+
1136
+ > **说明**: 墙钟耗时 = `run_all.py` 整组实验起止时间;Agent 累计耗时 = 各实验 `deveco run` 进程时间之和;HomeGraph 索引在 homegraph 组开头执行一次,计入墙钟但不计入各实验 `duration_s`。内存为各实验轮询采样得到的 Working Set 峰值。
1137
+
1138
+ ---
1139
+
1140
+ {_build_ab_validity_section(b_sum, h_sum, exp_ids, h_ag)}
1141
+
1142
+ ---
1143
+
1144
+ ## 耗时分解
1145
+
1146
+ | 阶段 | Baseline | HomeGraph | 说明 |
1147
+ |------|----------|-----------|------|
1148
+ | HomeGraph 索引 (`index`) | — | {_format_index_ms(h_ag.get('homegraph_index_ms', 0))} | 仅 homegraph 组开头执行一次 |
1149
+ | Agent 累计耗时 | {b_ag['total_dur_ms'] / 1000:.1f}s | {h_ag['total_dur_ms'] / 1000:.1f}s | 各实验 `duration_ms` 之和 |
1150
+ | 整组墙钟耗时 | {_format_wall_duration(baseline_wall_s) if baseline_wall_s else '—'} | {_format_wall_duration(homegraph_wall_s) if homegraph_wall_s else '—'} | 含 git 重置、setup、index、实验间隔 |
1151
+ | 墙钟 − Agent 累计 | {_overhead_s(baseline_wall_s, b_ag['total_dur_ms'])} | {_overhead_s(homegraph_wall_s, h_ag['total_dur_ms'], h_ag.get('homegraph_index_ms', 0))} | 近似 overhead |
1152
+
1153
+ ---
1154
+
1155
+ ## 逐实验对比
1156
+
1157
+ | Exp | Baseline 耗时 | HomeGraph 耗时 | Baseline 工具 | HomeGraph 工具 | Baseline HG | HomeGraph HG | Baseline Token | HomeGraph Token |
1158
+ |-----|--------------|----------------|---------------|----------------|-------------|--------------|----------------|-----------------|
1159
+ """
1160
+ for eid in exp_ids:
1161
+ br, hr = b_sum.get(eid, {}), h_sum.get(eid, {})
1162
+ b_dur = br.get("duration_s", "—")
1163
+ h_dur = hr.get("duration_s", "—")
1164
+ b_tc = br.get("tool_calls", "—")
1165
+ h_tc = hr.get("tool_calls", "—")
1166
+ b_hg = br.get("homegraph_tool_calls", 0)
1167
+ h_hg = hr.get("homegraph_tool_calls", 0)
1168
+ b_tok = br.get("input_tokens", 0)
1169
+ h_tok = hr.get("input_tokens", 0)
1170
+ b_tok_s = f"{b_tok // 1000}k" if isinstance(b_tok, int) else str(b_tok)
1171
+ h_tok_s = f"{h_tok // 1000}k" if isinstance(h_tok, int) else str(h_tok)
1172
+ title = br.get("title", hr.get("title", ""))[:16]
1173
+ md += f"| {eid} {title} | {b_dur} | {h_dur} | {b_tc} | {h_tc} | {b_hg} | {h_hg} | {b_tok_s} | {h_tok_s} |\n"
1174
+
1175
+ # Gear switching comparison if 1-1/1-2/1-3 present
1176
+ b_gs = baseline_data.get("gear_analysis", {})
1177
+ h_gs = homegraph_data.get("gear_analysis", {})
1178
+ if b_gs.get("valid") or h_gs.get("valid"):
1179
+ md += """
1180
+ ---
1181
+
1182
+ ## 档位切换 (Exp 1-1 → 1-3)
1183
+
1184
+ | 指标 | Baseline | HomeGraph |
1185
+ |------|----------|-----------|
1186
+ """
1187
+ md += f"| 工具跨度 | {b_gs.get('tool_span', '—')} | {h_gs.get('tool_span', '—')} |\n"
1188
+ md += f"| 耗时跨度 | {b_gs.get('dur_span', '—')} | {h_gs.get('dur_span', '—')} |\n"
1189
+ md += f"| 1-2 trap 生效 | {'是' if b_gs.get('trap_worked') else '否'} | {'是' if h_gs.get('trap_worked') else '否'} |\n"
1190
+ md += f"| 1-3 深度探索 | {'是' if b_gs.get('deep_confirmed') else '否'} | {'是' if h_gs.get('deep_confirmed') else '否'} |\n"
1191
+
1192
+ md += """
1193
+ ---
1194
+
1195
+ ## 评分对比(7 维度)
1196
+
1197
+ | 维度 | Baseline | HomeGraph |
1198
+ |------|----------|-----------|
1199
+ """
1200
+ b_scores = baseline_data.get("scores", {})
1201
+ h_scores = homegraph_data.get("scores", {})
1202
+ for dim in sorted(set(b_scores) | set(h_scores)):
1203
+ bs = b_scores.get(dim, 0)
1204
+ hs = h_scores.get(dim, 0)
1205
+ md += f"| {dim} | {_score_to_stars(bs)} ({bs}/5) | {_score_to_stars(hs)} ({hs}/5) |\n"
1206
+
1207
+ md += f"""
1208
+ ---
1209
+
1210
+ {_FIXED_EXPERIMENT_EXPECTATIONS}
1211
+
1212
+ *报告由 `analyze.py compare_runs` 自动生成*
1213
+ """
1214
+ return md
1215
+
1216
+
1217
+ def compare_runs(baseline_dir: Path, homegraph_dir: Path, *,
1218
+ agent: str = "", baseline_wall_s: int = 0,
1219
+ homegraph_wall_s: int = 0, verbose: bool = False) -> Path:
1220
+ """Write A/B compare report under output/compare/. Returns report path."""
1221
+ baseline_dir = resolve_results_dir(Path(baseline_dir))
1222
+ homegraph_dir = resolve_results_dir(Path(homegraph_dir))
1223
+ baseline_data = gather_data(baseline_dir)
1224
+ homegraph_data = gather_data(homegraph_dir)
1225
+
1226
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
1227
+ agent_key = agent or "default"
1228
+ compare_dir = OUTPUT_DIR / "compare" / agent_key / ts
1229
+ compare_dir.mkdir(parents=True, exist_ok=True)
1230
+
1231
+ report_path = compare_dir / "ab_compare_report.md"
1232
+ report_path.write_text(
1233
+ build_compare_md(baseline_dir, homegraph_dir, baseline_data, homegraph_data,
1234
+ agent=agent, baseline_wall_s=baseline_wall_s,
1235
+ homegraph_wall_s=homegraph_wall_s),
1236
+ encoding="utf-8",
1237
+ )
1238
+
1239
+ manifest = {
1240
+ "generated_at": datetime.now(timezone.utc).isoformat(),
1241
+ "agent": agent_key,
1242
+ "baseline_dir": str(baseline_dir),
1243
+ "homegraph_dir": str(homegraph_dir),
1244
+ "baseline_report": str(baseline_dir / "analysis_report.md"),
1245
+ "homegraph_report": str(homegraph_dir / "analysis_report.md"),
1246
+ "compare_report": str(report_path),
1247
+ "baseline_wall_s": baseline_wall_s,
1248
+ "homegraph_wall_s": homegraph_wall_s,
1249
+ }
1250
+ (compare_dir / "compare_manifest.json").write_text(
1251
+ json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8",
1252
+ )
1253
+
1254
+ if verbose:
1255
+ _configure_stdout()
1256
+ header("A/B 对比报告")
1257
+ print(f" Baseline wall : {_format_wall_duration(baseline_wall_s)}")
1258
+ print(f" HomeGraph wall: {_format_wall_duration(homegraph_wall_s)}")
1259
+ b_ag = baseline_data["aggregated"]
1260
+ h_ag = homegraph_data["aggregated"]
1261
+ print(f" Tools : {b_ag['total_tool_calls']} → {h_ag['total_tool_calls']} "
1262
+ f"({_pct_change(b_ag['total_tool_calls'], h_ag['total_tool_calls'])})")
1263
+ print(f" Input tokens : {b_ag['total_input_tokens']:,} → {h_ag['total_input_tokens']:,} "
1264
+ f"({_pct_change(b_ag['total_input_tokens'], h_ag['total_input_tokens'])})")
1265
+
1266
+ return report_path
1267
+
1268
+
1269
+ def print_report_locations(*, baseline_report: Optional[Path] = None,
1270
+ homegraph_report: Optional[Path] = None,
1271
+ compare_report: Optional[Path] = None,
1272
+ single_report: Optional[Path] = None):
1273
+ """Print report paths to console after a run completes."""
1274
+ _configure_stdout()
1275
+ header("报告已生成")
1276
+ if single_report:
1277
+ print(f" 单组分析报告 : {single_report}")
1278
+ if baseline_report:
1279
+ print(f" Baseline 报告: {baseline_report}")
1280
+ if homegraph_report:
1281
+ print(f" HomeGraph 报告: {homegraph_report}")
1282
+ if compare_report:
1283
+ print(f" A/B 对比报告 : {compare_report}")
1284
+ print(f"\n{GREEN}[ALL]{NC} 所有报告位于 output/ 目录下,可用 Markdown 阅读器打开。")
1285
+
1286
+
1287
+ # ═══════════════════════════════════════════════════════════
1288
+ # Main
1289
+ # ═══════════════════════════════════════════════════════════
1290
+
1291
+ def main():
1292
+ _configure_stdout()
1293
+ input_path = Path(sys.argv[1]) if len(sys.argv) > 1 else RESULTS_DIR
1294
+ if not input_path.is_dir():
1295
+ print(f"ERROR: Results directory not found: {input_path}")
1296
+ sys.exit(1)
1297
+
1298
+ results_dir = resolve_results_dir(input_path)
1299
+ if not _has_experiment_outputs(results_dir):
1300
+ print(f"ERROR: No experiment outputs found under: {input_path}")
1301
+ if input_path != results_dir:
1302
+ print(f" Also checked: {results_dir}")
1303
+ sys.exit(1)
1304
+
1305
+ try:
1306
+ analyze_run(results_dir, input_path=input_path, verbose=True)
1307
+ except ValueError as e:
1308
+ print(f"ERROR: {e}")
1309
+ sys.exit(1)
1310
+
1311
+
1312
+ if __name__ == "__main__":
1313
+ main()