dsh-molbio-tools 0.22.2 → 0.24.1

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 (57) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/README.md +51 -19
  3. package/batch.mjs +5 -1
  4. package/benchmark/README.md +7 -7
  5. package/benchmark/changed.mjs +4 -3
  6. package/benchmark/fixtures.mjs +32 -4
  7. package/benchmark/profile.mjs +1 -1
  8. package/benchmark/run.mjs +1 -0
  9. package/benchmark/sequences.mjs +1 -0
  10. package/benchmark/tasks/02-primers.json +3 -3
  11. package/benchmark/tasks/06-analysis.json +111 -1
  12. package/benchmark/tasks/09-formats.json +44 -1
  13. package/benchmark/verifications.mjs +13 -0
  14. package/build/client-bundle.mjs +1 -1
  15. package/build/client-entry.mjs +80 -1
  16. package/build/panel-core.mjs +85 -0
  17. package/build/preset-patch.mjs +1 -1
  18. package/build/release-preflight.mjs +1 -1
  19. package/charts.mjs +35 -16
  20. package/contamination.mjs +229 -0
  21. package/cordis.patch.yml +10 -6
  22. package/design.mjs +111 -78
  23. package/docs/capability-gap-survey.md +5 -0
  24. package/docs/client-panel.md +10 -8
  25. package/docs/history.md +86 -0
  26. package/docs/maintainer.md +8 -5
  27. package/docs/roadmap.md +37 -15
  28. package/docs/rules.md +5 -2
  29. package/docs/store-evidence.md +30 -23
  30. package/docs/store-preset-exemption-issue.md +1 -1
  31. package/docs/store-preset-exemption.md +1 -1
  32. package/docs/v24-plan.md +178 -0
  33. package/docs/workflow.md +11 -10
  34. package/dotplot.mjs +257 -0
  35. package/fastq-qc.mjs +31 -16
  36. package/fastq-trim.mjs +238 -0
  37. package/host.mjs +2 -2
  38. package/index.mjs +521 -34
  39. package/lib/client.js +376 -109
  40. package/package.json +8 -3
  41. package/papers.mjs +10 -0
  42. package/plot.mjs +24 -8
  43. package/preset/install.mjs +8 -0
  44. package/preset/molbio-lab/agent.cordis.yml +19 -13
  45. package/preset/molbio-lab/preset.yml +1 -1
  46. package/protein-charge.mjs +158 -0
  47. package/protein-structure.mjs +10 -2
  48. package/protein.mjs +36 -14
  49. package/records.mjs +17 -2
  50. package/seqio.mjs +49 -14
  51. package/test/charts.mjs +11 -0
  52. package/test/client.mjs +12 -5
  53. package/test/contract.mjs +44 -12
  54. package/test/drift-probe.mjs +15 -1
  55. package/test/map-card.mjs +139 -1
  56. package/test/preset-health.mjs +39 -0
  57. package/test/smoke.mjs +378 -11
@@ -0,0 +1,178 @@
1
+ # 0.24.0 施工计划(已实施)
2
+
3
+ > 状态:**已实施(0.24.0)**。实际范围 = survey #11 + #12 + 接头表 RNA→DNA 静默错修复;
4
+ > #9 / 2.9b / 2.9c 未纳入。下面的探索记录与备选方案保留原样,便于对照“计划 vs 落地”。
5
+ > 0.23.0 已提交并推送(`4050aa6`)。
6
+
7
+ ## 0. 一句话结论
8
+
9
+ **0.24.0 建议做 survey #11 + #12(若接受边界,再加 #9 的 perfect-duplex 子集);
10
+ 2.9c 放到 0.25.0;2.9b 暂缓,先解决宿主侧写回 RPC。**
11
+
12
+ | 候选 | 是否进 0.24.0 | 理由 |
13
+ | --- | --- | --- |
14
+ | survey #11 载体/接头污染筛查 | **建议加入** | 纯计算、已有接头表可复用、S–M、可直接加 benchmark |
15
+ | survey #12 FASTQ 修剪/过滤 | **建议加入** | 现有解析器齐备,只缺写出与修剪层;M、用户价值高 |
16
+ | survey #9 双链热力学 | **可选(只做 perfect duplex)** | 现有 `duplexThermo` 可导出;完整错配表/DMSO 是另一项工作 |
17
+ | 2.9c 结构预览 | **0.25.0** | 可行且不需要宿主 RPC,但解析器 + 客户端查看器是 M–L,和上面三项同版风险偏高 |
18
+ | 2.9b 文献库写回 | **暂缓** | `workspaceFiles` 明确只读;需要新增宿主 Typert Remote + 并发契约,属于架构项 |
19
+
20
+ ## 1. 评估口径
21
+
22
+ 沿用本仓库的既有约束:零运行时依赖、纯 JS、无外部二进制/数据库;工具行为变化必须
23
+ 更新 benchmark;客户端改动必须重建 `lib/client.js` 并通过四个客户端套件;不允许为了
24
+ 方便绕过 `ctx.fs`/sandboxPolicy。
25
+
26
+ ## 2. 逐项探索
27
+
28
+ ### 2.1 2.9b 文献库写回:被“只读 Remote”挡住
29
+
30
+ 现状:
31
+
32
+ - `molbio_paper_*` 工具通过 `papers.mjs` 的 `loadLibrary`/`saveLibrary` 读写 `papers.json`;
33
+ `addPapers`/`updatePaper`/`removePaper` 已定义去重与字段合并语义。
34
+ - 面板的 Papers 页只调用 `ctx.remote.workspaceFiles.read`,是刻意只读的
35
+ ([client-panel.md](client-panel.md) §3)。
36
+ - DSH 的 `workspaceFiles` 服务在包 README 里写明 **“this service exposes no mutations”**,
37
+ 类型里也只有 `read` / `readBytes` / `stat` / `list` / `changes`。
38
+ - 本包客户端半**没有**宿主侧 RPC;这是为了避开 Typert Remote 的版本耦合而做的取舍
39
+ ([client-panel.md](client-panel.md) §3)。
40
+
41
+ 所以“面板里改笔记/标签”不是一个 UI 小功能,而是:
42
+
43
+ 1. 在宿主侧新增一个 Typert Remote 服务(或让面板走与工具相同的写路径);
44
+ 2. 定义乐观版本号/串行化队列,避免与 `molbio_paper_update` 并发时丢更新;
45
+ 3. 客户端增加对该 Remote 的注入与失败降级;
46
+ 4. 处理 panel-only 包在没有该宿主服务时的行为。
47
+
48
+ **结论:不在 0.24.0 做。** 如果要做,先做一个只含 `describe`/`mutate(expectedVersion)` 的
49
+ 宿主 Remote spike,再谈 UI;不要先做表单。
50
+
51
+ ### 2.2 2.9c 结构预览:可行,且不需要宿主 RPC
52
+
53
+ DSH 的文档预览容器提供了两条可复用路径:
54
+
55
+ - `ctx.documentPreviews.register({ id, extensions, priority, title, loading })` 注册元数据;
56
+ - 在 keyed 座位 `sidebar.right.tab.document` 下用同一 `id` 注册正文;
57
+ - 正文收到 `resourceAddress` 与 `content`;`loading: 'bytes-complete'` 时
58
+ `content.kind === 'bytes'`,可直接 `TextDecoder` 解码 PDB/mmCIF/SDF/MOL 文本。
59
+
60
+ 这与本包“浏览器里跑自己的纯模块”的模式一致,不需要新增宿主 RPC。需要新建:
61
+
62
+ - `structure-preview.mjs`:PDB `ATOM/HETATM`、mmCIF `_atom_site`、SDF/MOL V2000
63
+ (V3000 可后续)解析;Cα 轨迹/键的 2D 投影(PCA 或给定 2D 坐标);SVG。
64
+ - 客户端正文:注册 preview + keyed body;空/坏/超大文件降级为说明;不抛异常。
65
+ - 测试:纯解析器已知坐标/键数;客户端 stub 驱动 preview 注册与渲染;contract 计数更新。
66
+ - 文档与 CHANGELOG;面板包 `0.1.8 → 0.1.9`(客户端产物变了)。
67
+
68
+ **结论:技术上可行,但规模是 M–L,建议单独作为 0.25.0 的主功能。** 0.24.0 若也做,
69
+ 会把“纯工具版”变成“工具 + 新查看器”双线,测试面太大。
70
+
71
+ ### 2.3 survey #9 双链热力学:可做子集,完整版不急
72
+
73
+ 现状:
74
+
75
+ - `lib.mjs` 已有 SantaLucia 1998 NN 表、`primerTm`(含 von Ahsen Mg²⁺ 等效盐校正)、
76
+ `duplexThermo`、`deltaG37`、`duplexTmFromThermo`;后三者目前是模块私有。
77
+ - `molbio_primer_check` 只报 3' 端 5-mer 的 `end_stability_kcal`,没有整条双链的
78
+ ΔH/ΔS/ΔG°37。
79
+
80
+ 可低成本交付的 **perfect-duplex 子集**:
81
+
82
+ - 输入一条链(另一条按反向互补生成),或两条链并校验完全互补;
83
+ - 输出 `delta_h_kcal`、`delta_s_cal`、`delta_g37_kcal`、`tm_celsius`、盐条件;
84
+ - 自互补链的 `Ct` 因子单独处理;
85
+ - 明确写“只支持完全互补双链;错配、凸起、给定二级结构、DMSO 未建模”。
86
+
87
+ 完整版需要 Allawi/SantaLucia 错配表(转写 + 独立校验)、loop 表与 DMSO 经验校正,
88
+ 是 M 级且容易“数字看起来对、其实不可互换”。**建议:0.24.0 只在接受上述边界时加入;
89
+ 否则留给后续专门版本。**
90
+
91
+ ### 2.4 survey #11 载体/接头污染筛查:最合适的 0.24.0 候选
92
+
93
+ 现状:
94
+
95
+ - `fastq-qc.mjs` 已有 `ADAPTERS`(Illumina Universal/Small RNA、Nextera、PolyA/PolyG)
96
+ 与 read-level 的 adapter content 面板。
97
+ - **已发现一个现有静默错**:Small RNA 5' 的条目是 RNA(`GUUCAG…`,含 U),而
98
+ `adapterContent` 直接拿它去 `indexOf` DNA reads;正向分子永远匹配不上,只有它的反向互补
99
+ 能命中其中一条。实测两条(DNA 正向/反向互补)只报 1 条。修法是筛查前把表归一化成 DNA
100
+ (U→T)或分别生成 DNA 正向/反向 needle;#11 应把这条一起修,并加双向断言。
101
+ - 但没有独立工具,也没有载体骨架签名;不能对单条序列/组装结果做筛查。
102
+
103
+ 建议实现:
104
+
105
+ - 新建 `contamination.mjs`:把 `ADAPTERS` 移到这里并从 `fastq-qc.mjs` 再导出(保持兼容),
106
+ 增加 `VECTORS`(pUC/pBR322、pET、pcDNA、Gateway attB/attR 等短签名)。
107
+ - `screenContamination(sequenceOrEntries, { maxMismatches, sampleReads })`:
108
+ 正/反向互补、位置、链、错配数、命中条目;序列输入与 FASTA/FASTQ 路径都支持。
109
+ - 工具 `molbio_contamination_screen`;默认 exact,`max_mismatches` 可选 0–2。
110
+ - 输出明确写“短签名筛查,不是 BLAST/全库比对;未命中不等于无污染”。
111
+
112
+ 规模 S–M;新增 1 个工具、1 条 benchmark;不需要客户端改动。
113
+ 风险是短签名假阳性/假阴性,必须把表来源、长度和局限写进描述与 README。
114
+
115
+ ### 2.5 survey #12 FASTQ 修剪/过滤:用户价值最高,但要把边界切清楚
116
+
117
+ 现状:
118
+
119
+ - `seqio.mjs` 有 `parseFastq`(容忍多行)与 `toFasta`,**没有** `toFastq` 与修剪层;
120
+ - `molbio_fasta_fastq` 只做 stats/extract/convert/qc;`molbio_fastq_qc` 只读不写。
121
+
122
+ 建议先做**一个**工具 `molbio_fastq_trim`,覆盖:
123
+
124
+ - 3' 滑窗质量修剪(窗口均值阈值)与 5' 端低质量修剪;
125
+ - poly-G / poly-A 尾部修剪;
126
+ - 3' 接头 / 5' 引物精确或 seed 匹配修剪;
127
+ - 最小长度、最大 N、可选 subsample;
128
+ - Phred+33/+64 输入(输出保持同一编码);
129
+ - 写 FASTQ 或 FASTA(`ctx.fs` + sandboxPolicy)。
130
+
131
+ 明确不做:paired-end overlap merge、复杂 semiglobal adapter alignment、UMI 处理
132
+ (这些是另一个规模;不要在一个工具里假装做了)。
133
+
134
+ 规模 M;新增 1 个工具、1 条 benchmark;不需要客户端改动。
135
+ 测试应钉住:每条 read 的修剪后长度/质量、poly-G/A 边界、adapter 位置、过滤计数、
136
+ 输出 FASTQ 四行结构与 round-trip。
137
+
138
+ ## 3. 推荐的 0.24.0 范围
139
+
140
+ **主选(低风险、纯工具):#11 + #12。**
141
+
142
+ - 工具数 62 → **64**;benchmark 63 → **65**;覆盖 64/64。
143
+ - 新增 `contamination.mjs`、`fastq-trim.mjs`;`seqio.mjs` 增加 `toFastq`。
144
+ - `seqio.mjs` 新增 `toFastq` 会进入浏览器半,因此两个客户端产物需重建;面板包 0.1.8 → 0.1.9(行为未变,只是产物字节变了)。
145
+ - 文档四处计数 + README 工具表 + benchmark tasks + CHANGELOG/history/roadmap。
146
+
147
+ **可选加项:** #9 的 perfect-duplex 子集(工具数 65、benchmark 66),前提是接受
148
+ “不支持错配/DMSO/指定二级结构”的边界,并在描述里写明。
149
+
150
+ **不在 0.24.0:** 2.9b(宿主 RPC + 并发)、2.9c(M–L 客户端查看器)、#9 完整错配表。
151
+
152
+ ## 4. 施工顺序(若批准主选)
153
+
154
+ 1. `contamination.mjs` + 表 + `molbio_contamination_screen`;smoke 已知值;
155
+ 2. `fastq-trim.mjs` + `toFastq` + `molbio_fastq_trim`;smoke 已知值;
156
+ 3. 两条 benchmark 任务 + `verifications.mjs` invocation;`--offline` 先绿;
157
+ 4. `test/benchmark-coverage` 64/64;`test/benchmark-profile` 工具数;
158
+ 5. README/preset 描述/index 顶部注释/agent.cordis.yml 注释四处同步;
159
+ 6. `npm test`、`bench --offline`、定向模型 benchmark(`--tools contamination_screen,fastq_trim`);
160
+ 7. CHANGELOG 0.24.0、docs/history、roadmap;版本 0.24.0;commit。
161
+
162
+ ## 5. 风险与开放问题
163
+
164
+ - **#11 的签名表**:短签名会假阳性;必须给出来源、长度、默认 exact、未命中声明。
165
+ 需要避免复制有许可证负担的长载体序列。
166
+ - **#12 的“修剪到什么程度”**:滑窗阈值、poly-G 最小 run、adapter 最小匹配长度都要
167
+ 有默认值与边界测试;不要一次把 Trimmomatic/fastp 的所有功能塞进来。
168
+ - **#9 的自互补 Ct 因子**:若做,需用已知序列钉住公式,否则会和 `primerTm` 的
169
+ 非自互补假设悄悄分叉。
170
+ - **2.9c 的 documentPreviews 注入**:若在 0.25.0 做,先验证 `ctx.inject(['documentPreviews'])`
171
+ 在没有该服务的 panel-only profile 中会安全跳过;不要把整个面板 entry 变成硬依赖。
172
+ - **benchmark 成本**:每条新任务都是 full-only;发版前用 `--tools` 定向跑,不默认全量。
173
+
174
+ ## 6. 备选方案(若 0.24.0 想要“看得见”的功能)
175
+
176
+ 把 0.24.0 改成 **2.9c + #11**:结构预览作为主功能,#11 作为配套纯工具;
177
+ #12 与 #9 推到 0.24.1/0.25.0。这个方案的用户可见价值更高,但客户端测试面明显更大,
178
+ 且 0.24.0 的发版周期会更长。默认不推荐。
package/docs/workflow.md CHANGED
@@ -20,13 +20,13 @@
20
20
  | 使用 | 模型找得到、用得对吗? | `benchmark/`(见 [benchmark/README.md](../benchmark/README.md)) |
21
21
 
22
22
  ```bash
23
- node test/smoke.mjs # 插件:mock 注册表跑全部 60 个工具 + 输出 schema 校验
23
+ node test/smoke.mjs # 插件:mock 注册表跑全部 64 个工具 + 输出 schema 校验
24
24
  node test/svgpng.mjs # 光栅化器:PNG 结构 + inflate 回像素断言 + 真实渲染器子集检查
25
25
  node test/svgio.mjs # 共享绘图助手:几何手算值 + 每种助手拼一张文档后光栅化必须干净
26
26
  node test/charts.mjs # 图表:CSV/TSV 读取层 + 八种图表(含色带)的统计量与光栅化
27
27
  node test/client.mjs # 客户端产物:按加载器方式执行 + 面板数据通路(无浏览器)
28
28
  node test/panel-render.mjs # 面板组件:最小钩子宿主里跑真实组件(无 React、无 DOM)
29
- node test/map-card.mjs # 图谱调用卡:跨界 meta 投影断言
29
+ node test/map-card.mjs # 调用卡(图谱 + logo/gRNA):跨界 meta 投影断言
30
30
  node test/client-mount.mjs # 客户端挂载:复刻宿主侧图扫描,核对 web profile 的行与依赖
31
31
  node test/contract.mjs # DSH 契约:官方是否改了规则或 API
32
32
  node test/preset-health.mjs # 组合:逐行按该包自己的 Config schema 校验 preset 可挂载性
@@ -50,7 +50,7 @@ node benchmark/run.mjs --offline # benchmark 的期望值是否仍等于工具
50
50
 
51
51
  ### 各套件到底在防什么
52
52
 
53
- - **`smoke.mjs`** 证明**插件**可用:mock 注册表运行全部 60 个工具,并用 harness 自身的
53
+ - **`smoke.mjs`** 证明**插件**可用:mock 注册表运行全部 64 个工具,并用 harness 自身的
54
54
  `assertSupportedJsonSchema` / `validateJsonSchemaValue` 校验每个输出 schema 与返回值;
55
55
  覆盖已知值用例(EcoRI 酶切、ΔΔCt=-3 → fold 8、GenBank/SnapGene 解析、引物对一致性、
56
56
  SVG 文件写入与无旋转标签断言、克隆模拟手算序列比对、合成 ABIF 夹具、环状参考跨原点
@@ -218,7 +218,7 @@ benchmark 不是"每天跑一遍"的门禁,而是**行为的验收测试**—
218
218
 
219
219
  | 这次改了什么 | benchmark 要不要动 | 为什么 |
220
220
  | --- | --- | --- |
221
- | 新增一个工具 | **必须**:加任务 + `verifications.mjs` 的 invocation | `test/benchmark-coverage.mjs` 会**直接 FAIL**(`60/60 tools covered` 是断言) |
221
+ | 新增一个工具 | **必须**:加任务 + `verifications.mjs` 的 invocation | `test/benchmark-coverage.mjs` 会**直接 FAIL**(`64/64 tools covered` 是断言) |
222
222
  | 改工具的参数、默认值、输出字段、渲染文本 | **必须**:更新受影响任务的断言,重跑 `--offline`,再重跑该任务 | 断言比对的是**渲染文本**,改了文本就改了对错 |
223
223
  | 改工具的描述或参数说明(prompt 面) | **必须**:重跑对应任务 | 这正是 benchmark 唯一能测的东西——模型是否还选得对 |
224
224
  | 改 `preset/molbio-lab/agent.cordis.yml`(行清单) | **必须**:先 `node test/benchmark-profile.mjs`,再重建 profile | 行清单变了,benchmark 的 headless profile 是**推导**出来的,守卫会 FAIL |
@@ -237,7 +237,7 @@ benchmark 不是"每天跑一遍"的门禁,而是**行为的验收测试**—
237
237
 
238
238
  ```bash
239
239
  node benchmark/run.mjs --offline # 每条 where:"tool" 断言是否仍等于工具的真实渲染输出
240
- node test/benchmark-coverage.mjs # 60/60 工具有任务覆盖、fixture 前提仍成立、tier 与占位符健全
240
+ node test/benchmark-coverage.mjs # 64/64 工具有任务覆盖、fixture 前提仍成立、tier 与占位符健全
241
241
  node test/benchmark-score.mjs # 判分器对冻结的真实响应仍判对,且仍能判错
242
242
  ```
243
243
 
@@ -254,7 +254,7 @@ offline 证明"期望值仍然为真",model 测量"模型是否仍然会用"
254
254
  | 改了**几个**工具 | `--tools a,b,c`(也接受 `molbio_` 前缀与 `primer_*` 通配) | 并集 |
255
255
  | 改了**一个**已知任务 | `--task <id>` | 一题 |
256
256
  | 改了**共享模块**,想知道影响面 | `--list --changed`(不花钱) | 0 |
257
- | 发版前 | `node --run bench:full` | 61 题 |
257
+ | 发版前 | `node --run bench:full` | 65 题 |
258
258
  | 只是提交文档 | `--changed` 会回答"无事可跑" | 0 |
259
259
 
260
260
  ```bash
@@ -299,7 +299,7 @@ $ touch lib.mjs && node benchmark/run.mjs --list --changed
299
299
  1. **不可判定 → 全量**:没有 git / 没有 HEAD / 读不到插件结构,一律回落全量并打印原因;
300
300
  2. **认不出的文件 → 全量**:新模块、重命名的文件(`not in the plugin's module set`);
301
301
  3. **`index.mjs` → 全量**:它是绑定全部工具的那个入口;
302
- 4. **映射到的工具有题没覆盖 → 全量**(并由 `test/benchmark-coverage.mjs` 断言 60/60 覆盖)。
302
+ 4. **映射到的工具有题没覆盖 → 全量**(并由 `test/benchmark-coverage.mjs` 断言 64/64 覆盖)。
303
303
 
304
304
  反过来,**唯一会返回"无事可跑"的情况**是改动**可证明**不影响工具行为:`docs/`、
305
305
  `CHANGELOG.md`、`README.md`、`test/`、`benchmark/`、`package.json`。这不是走后门——
@@ -363,7 +363,7 @@ node test/client.mjs && node test/panel-render.mjs && node test/map-card.mjs &&
363
363
  两条实现纪律:投影**必须廉价且不抛**(它跑在调用成功之后,抛错会把这次调用标成失败;
364
364
  图谱标记因此走一份有界内存缓存而不是读文件),卡片**必须校验而非信任** meta(缺失/异种/
365
365
  异形/超限/失败一律降级为提示,绝不在对话里抛错)。新增卡片时:把工具名加进
366
- `build/client-entry.mjs` 的 `MAP_TOOL_KEYS`,并在 `test/map-card.mjs` 里补一条跨界断言。
366
+ `build/client-entry.mjs` 的 `MAP_TOOL_KEYS`(图谱)或 `FIGURE_TOOL_KEYS`(图形),并在 `test/map-card.mjs` 里补一条跨界断言。
367
367
  另注意 `define()` 包装器会转发 `presentationMeta`——若哪天再包一层,别忘了这条。
368
368
 
369
369
  ### 安装与修复面板
@@ -456,7 +456,7 @@ git status --short # lib/client.js 与 packages/molbio-panel/lib/clien
456
456
  ### DSH 升级后:preset 组合的维护(必做)
457
457
 
458
458
  `preset/molbio-lab/agent.cordis.yml` 是官方 `standard` 预设的**行清单**副本 + 末尾一行
459
- `tool-molbio`(当前基线:**dsh 0.2.1-rc.5**)。它不会自动跟随 DSH 升级,因此每次升级
459
+ `tool-molbio`(当前基线:**dsh 0.2.2-rc.1**)。它不会自动跟随 DSH 升级,因此每次升级
460
460
  DSH 后:
461
461
 
462
462
  1. 取新版的 shipped `standard`:
@@ -469,7 +469,8 @@ DSH 后:
469
469
  只比结构,注释漂移抓不住,但下一次人工 diff 会;
470
470
  3. 跑 `node build/preset-patch.mjs` 重新生成根目录的 `cordis.patch.yml`;
471
471
  4. 跑 `node test/preset-health.mjs` 直到 `OK`(它把任何结构差异当**发布阻断**,不只是打印
472
- note),再跑 `node test/drift-probe.mjs` 确认守卫仍能抓到漂移;
472
+ note;它还会按 Loader 的方式解析每个行名,上游删掉的子路径会在这里直接 FAIL),
473
+ 再跑 `node test/drift-probe.mjs` 确认守卫仍能抓到漂移;
473
474
  5. 更新 `agent.cordis.yml` 头部"Baseline: …"那行里的 DSH 版本号;
474
475
  6. 跑 `node test/benchmark-profile.mjs`:benchmark 的 headless profile 是从这份行清单**推导**
475
476
  出来的,上游加行、改 config 都会在这里失败(它会把每一行逐字段比对),按提示把新行分类
package/dotplot.mjs ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * dsh-molbio-tools/dotplot.mjs
3
+ *
4
+ * Self-comparison dotplot by exact word matching: a dot at (i, j) means the
5
+ * k-mer starting at i equals the k-mer starting at j (direct repeat), and a red
6
+ * dot means it equals the reverse complement of the k-mer at j (inverted
7
+ * repeat). Pure computation; the SVG is binned into a fixed pixel grid so a
8
+ * repetitive sequence cannot produce a million-element document.
9
+ *
10
+ * Deliberately NOT a window/identity dotplot (EMBOSS dotmatcher-style): this is
11
+ * the k-mer view, which is exact, deterministic and needs no scoring matrix.
12
+ */
13
+ import { MolbioInputError, normalizeSequence } from './lib.mjs';
14
+ import { axis, linearScale, niceTicks, panel, rect, svgDocument, textRun } from './svgio.mjs';
15
+
16
+ /** Accepted ranges and work caps. */
17
+ export const DOTPLOT_LIMITS = {
18
+ minWord: 4,
19
+ maxWord: 12,
20
+ maxLength: 20_000,
21
+ maxPairs: 200_000,
22
+ maxCells: 60_000,
23
+ grid: 400,
24
+ };
25
+
26
+ const round = (value, digits = 3) => Math.round(value * 10 ** digits) / 10 ** digits;
27
+
28
+ /** Reverse complement of one canonical k-mer (A/C/G/T only). */
29
+ function rcWord(word) {
30
+ let out = '';
31
+ for (let index = word.length - 1; index >= 0; index--) {
32
+ const base = word[index];
33
+ out += base === 'A' ? 'T' : base === 'C' ? 'G' : base === 'G' ? 'C' : 'A';
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * Compute the self-comparison dotplot.
40
+ *
41
+ * @param {string} raw DNA sequence (IUPAC; ambiguous k-mers are skipped).
42
+ * @param {{word?: number, mode?: 'direct'|'inverted'|'both', grid?: number}} [options]
43
+ * @returns {{length: number, word: number, mode: string, direct_matches: number,
44
+ * inverted_matches: number, direct_cells: number, inverted_cells: number,
45
+ * ambiguous_bases: number, truncated: boolean, plot_truncated: boolean,
46
+ * grid: number, directGrid: Set<number>, invertedGrid: Set<number>}}
47
+ */
48
+ export function dotplot(raw, options = {}) {
49
+ const sequence = normalizeSequence(raw, 'sequence');
50
+ if (sequence.length > DOTPLOT_LIMITS.maxLength) {
51
+ throw new MolbioInputError('the sequence is ' + sequence.length + ' bp; the dotplot limit is ' + DOTPLOT_LIMITS.maxLength + ' bp');
52
+ }
53
+ const word = options.word ?? 8;
54
+ if (!Number.isInteger(word) || word < DOTPLOT_LIMITS.minWord || word > DOTPLOT_LIMITS.maxWord) {
55
+ throw new MolbioInputError('word must be an integer between ' + DOTPLOT_LIMITS.minWord + ' and ' + DOTPLOT_LIMITS.maxWord);
56
+ }
57
+ if (sequence.length < word * 2) {
58
+ throw new MolbioInputError('the sequence is ' + sequence.length + ' bp; at word ' + word + ' it needs at least ' + word * 2 + ' bp to show a repeat');
59
+ }
60
+ const mode = options.mode ?? 'both';
61
+ if (!['direct', 'inverted', 'both'].includes(mode)) {
62
+ throw new MolbioInputError('mode must be "direct", "inverted" or "both"');
63
+ }
64
+ const grid = options.grid ?? DOTPLOT_LIMITS.grid;
65
+ if (!Number.isInteger(grid) || grid < 50 || grid > 1000) {
66
+ throw new MolbioInputError('grid must be an integer between 50 and 1000');
67
+ }
68
+
69
+ const index = new Map();
70
+ let ambiguousBases = 0;
71
+ for (let start = 0; start + word <= sequence.length; start++) {
72
+ const wordText = sequence.slice(start, start + word);
73
+ let canonical = true;
74
+ for (const base of wordText) {
75
+ if (base !== 'A' && base !== 'C' && base !== 'G' && base !== 'T') {
76
+ canonical = false;
77
+ break;
78
+ }
79
+ }
80
+ if (!canonical) {
81
+ ambiguousBases++;
82
+ continue;
83
+ }
84
+ const list = index.get(wordText);
85
+ if (list === undefined) index.set(wordText, [start]);
86
+ else list.push(start);
87
+ }
88
+
89
+ const length = sequence.length;
90
+ const cellOf = (position) => Math.min(grid - 1, Math.floor((position / length) * grid));
91
+ const directGrid = new Set();
92
+ const invertedGrid = new Set();
93
+ let directMatches = 0;
94
+ let invertedMatches = 0;
95
+ let truncated = false;
96
+ let plotTruncated = false;
97
+ let work = 0;
98
+
99
+ const addCell = (set, x, y) => {
100
+ const key = x * 4096 + y;
101
+ if (set.has(key)) return;
102
+ // Bound the RENDERED cells, not just the match count: a dense plot must not
103
+ // produce a document with one <rect> per match.
104
+ if (directGrid.size + invertedGrid.size >= DOTPLOT_LIMITS.maxCells) {
105
+ plotTruncated = true;
106
+ return;
107
+ }
108
+ set.add(key);
109
+ };
110
+
111
+ outer: for (const [wordText, positions] of index) {
112
+ if (mode === 'direct' || mode === 'both') {
113
+ for (let a = 0; a < positions.length; a++) {
114
+ for (let b = 0; b < positions.length; b++) {
115
+ if (work >= DOTPLOT_LIMITS.maxPairs) {
116
+ truncated = true;
117
+ break outer;
118
+ }
119
+ work++;
120
+ directMatches++;
121
+ addCell(directGrid, cellOf(positions[a]), cellOf(positions[b]));
122
+ }
123
+ }
124
+ }
125
+ if (mode === 'inverted' || mode === 'both') {
126
+ const rc = rcWord(wordText);
127
+ if (rc === wordText) continue; // a palindromic word would double-count the same pairs
128
+ const rcPositions = index.get(rc);
129
+ if (rcPositions === undefined) continue;
130
+ for (const from of positions) {
131
+ for (const to of rcPositions) {
132
+ if (work >= DOTPLOT_LIMITS.maxPairs) {
133
+ truncated = true;
134
+ break outer;
135
+ }
136
+ work++;
137
+ invertedMatches++;
138
+ addCell(invertedGrid, cellOf(from), cellOf(to));
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ return {
145
+ length,
146
+ word,
147
+ mode,
148
+ direct_matches: directMatches,
149
+ inverted_matches: invertedMatches,
150
+ direct_cells: directGrid.size,
151
+ inverted_cells: invertedGrid.size,
152
+ ambiguous_bases: ambiguousBases,
153
+ truncated,
154
+ plot_truncated: plotTruncated,
155
+ grid,
156
+ directGrid,
157
+ invertedGrid,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Render a computed dotplot as an SVG document. The grid is binned: one
163
+ * `<rect>` per occupied cell, so the document size is bounded by the grid, not
164
+ * by the number of matches.
165
+ *
166
+ * @param {ReturnType<typeof dotplot>} data
167
+ * @param {{title?: string}} [options]
168
+ * @returns {string} SVG text.
169
+ */
170
+ export function renderDotplot(data, options = {}) {
171
+ const width = 720;
172
+ // The x axis needs a title line BELOW its tick labels, and the legend needs a
173
+ // home INSIDE the panel. At 720 px the plot was 628 px square and the title /
174
+ // legend fell on (or outside) the bottom border; 760 px keeps the plot square
175
+ // (same 628 px) while giving both a row of their own. `bottom: 66` is what
176
+ // preserves that square: 728 (frame) - 34 (top) - 66 = 628.
177
+ const height = 760;
178
+ const frame = panel({
179
+ x: 16,
180
+ y: 16,
181
+ width: width - 32,
182
+ height: height - 32,
183
+ inset: { top: 34, right: 14, bottom: 66, left: 46 },
184
+ title: options.title ?? 'Sequence dotplot',
185
+ subtitle: data.length + ' bp · word ' + data.word + ' · ' + data.mode + ' · ' + data.direct_matches + ' direct / ' + data.inverted_matches + ' inverted match(es)'
186
+ + (data.truncated ? ' · scan capped' : '') + (data.ambiguous_bases > 0 ? ' · ' + data.ambiguous_bases + ' ambiguous k-mer(s) skipped' : ''),
187
+ });
188
+ const plot = frame.plot;
189
+ const x = linearScale([1, data.length], [plot.x, plot.x + plot.width]);
190
+ const y = linearScale([1, data.length], [plot.y + plot.height, plot.y]);
191
+ const cellWidth = plot.width / data.grid;
192
+ const cellHeight = plot.height / data.grid;
193
+ const parts = [frame.markup];
194
+ const drawCells = (set, fill, opacity) => {
195
+ const cells = [];
196
+ for (const key of set) {
197
+ const cx = Math.floor(key / 4096);
198
+ const cy = key - cx * 4096;
199
+ cells.push(rect({
200
+ x: round(plot.x + cx * cellWidth, 2),
201
+ y: round(plot.y + cy * cellHeight, 2),
202
+ width: Math.max(1, round(cellWidth, 2)),
203
+ height: Math.max(1, round(cellHeight, 2)),
204
+ fill,
205
+ fillOpacity: opacity,
206
+ }));
207
+ }
208
+ return cells.join('\n');
209
+ };
210
+ if (data.invertedGrid.size > 0) parts.push(drawCells(data.invertedGrid, '#cf222e', 0.55));
211
+ if (data.directGrid.size > 0) parts.push(drawCells(data.directGrid, '#24292f', 0.7));
212
+ parts.push(axis({
213
+ from: plot.x,
214
+ to: plot.x + plot.width,
215
+ at: plot.y + plot.height,
216
+ ticks: niceTicks([1, data.length], 6).map((tick) => x.of(tick)),
217
+ orientation: 'x',
218
+ tickLabel: (pixel) => String(Math.round(x.invert(pixel))),
219
+ title: 'position (bp)',
220
+ }));
221
+ parts.push(axis({
222
+ from: plot.y,
223
+ to: plot.y + plot.height,
224
+ at: plot.x,
225
+ ticks: niceTicks([1, data.length], 6).map((tick) => y.of(tick)),
226
+ orientation: 'y',
227
+ tickLabel: (pixel) => String(Math.round(y.invert(pixel))),
228
+ title: 'position (bp)',
229
+ }));
230
+ parts.push(legend(data));
231
+ return svgDocument({
232
+ width,
233
+ height,
234
+ title: options.title ?? 'Sequence dotplot',
235
+ description: 'Exact ' + data.word + '-mer self-comparison of a ' + data.length + ' bp sequence; black cells are direct repeats, red cells are inverted repeats.',
236
+ body: parts.join('\n'),
237
+ });
238
+ }
239
+
240
+ /** A compact legend; omitted when the corresponding mode produced no cells. */
241
+ function legend(data) {
242
+ const entries = [];
243
+ // Only label a class that was actually computed AND has dots: in
244
+ // inverted-only mode a black "direct repeat" key would claim a measurement
245
+ // that never ran.
246
+ if (data.directGrid.size > 0) entries.push(['#24292f', 'direct repeat']);
247
+ if (data.invertedGrid.size > 0) entries.push(['#cf222e', 'inverted repeat']);
248
+ let x = 28;
249
+ const y = 730;
250
+ const parts = [];
251
+ for (const [color, label] of entries) {
252
+ parts.push(rect({ x, y: y - 8, width: 9, height: 9, fill: color }));
253
+ parts.push(textRun({ x: x + 13, y, text: label, size: 9, fill: '#57606a' }));
254
+ x += 130;
255
+ }
256
+ return parts.join('\n');
257
+ }
package/fastq-qc.mjs CHANGED
@@ -28,6 +28,11 @@
28
28
  */
29
29
 
30
30
  import { MolbioInputError } from './lib.mjs';
31
+ import { ADAPTERS } from './contamination.mjs';
32
+
33
+ // Re-exported for callers that used this module's adapter table directly. The
34
+ // table now lives in contamination.mjs and is DNA-normalized there.
35
+ export { ADAPTERS } from './contamination.mjs';
31
36
  import {
32
37
  axis,
33
38
  escapeXml,
@@ -63,15 +68,7 @@ export const FASTQ_QC_DEFAULTS = {
63
68
  duplication_bins: 10,
64
69
  };
65
70
 
66
- /** Public adapter/primer fragments used for the adapter-content screen. */
67
- export const ADAPTERS = [
68
- { name: 'Illumina Universal', sequence: 'AGATCGGAAGAG' },
69
- { name: 'Illumina Small RNA 3\'', sequence: 'TGGAATTCTCGG' },
70
- { name: 'Illumina Small RNA 5\'', sequence: 'GUUCAGAGUUCUACAGUCCGACGAUC' },
71
- { name: 'Nextera Transposase', sequence: 'CTGTCTCTTATA' },
72
- { name: 'PolyA', sequence: 'AAAAAAAAAAAA' },
73
- { name: 'PolyG', sequence: 'GGGGGGGGGGGG' },
74
- ];
71
+ /** Public adapter/primer fragments: see contamination.mjs (DNA-normalized). */
75
72
 
76
73
  /** Phred+33 decoding, with the offending read id in the message. */
77
74
  function phredOf(character) {
@@ -118,7 +115,10 @@ function entropyOf(counts) {
118
115
  * that tapers is not mistaken for a quality drop.
119
116
  */
120
117
  function perPosition(entries, maxBases) {
121
- const longest = Math.max(...entries.map((entry) => entry.sequence.length));
118
+ // A loop, not Math.max(...lengths): a 200k-read run is within the supported
119
+ // cap and would otherwise overflow the argument limit with a RangeError.
120
+ let longest = 0;
121
+ for (const entry of entries) if (entry.sequence.length > longest) longest = entry.sequence.length;
122
122
  const length = Math.min(longest, maxBases);
123
123
  const rows = [];
124
124
  for (let position = 0; position < length; position++) {
@@ -414,9 +414,24 @@ export function fastqQcReport(entries, options = {}) {
414
414
  }
415
415
 
416
416
  const perBase = perPosition(selected, settings.max_plot_bases);
417
- const summaryQualities = selected.flatMap((entry) => [...entry.quality].map(phredOf));
418
- const q20 = summaryQualities.filter((value) => value >= 20).length;
419
- const q30 = summaryQualities.filter((value) => value >= 30).length;
417
+ // One streaming pass over the qualities. The previous version materialised
418
+ // every Phred score of every sampled read in one array (200k reads x 150 bp
419
+ // is 30 million numbers) and then filtered it twice; the counters need none
420
+ // of that memory, and the statistics are unchanged.
421
+ let qualitySum = 0;
422
+ let qualityCount = 0;
423
+ let q20 = 0;
424
+ let q30 = 0;
425
+ for (const entry of selected) {
426
+ for (const character of entry.quality) {
427
+ const value = phredOf(character);
428
+ qualitySum += value;
429
+ qualityCount++;
430
+ if (value >= 20) q20++;
431
+ if (value >= 30) q30++;
432
+ }
433
+ }
434
+ if (qualityCount === 0) throw new MolbioInputError('the FASTQ reads contain no quality values');
420
435
  const gcCount = selected.reduce((sum, entry) => {
421
436
  let gc = 0;
422
437
  for (const base of entry.sequence) if (base === 'G' || base === 'C') gc++;
@@ -444,9 +459,9 @@ export function fastqQcReport(entries, options = {}) {
444
459
  gc_percent: Math.round(gcFraction * 10000) / 100,
445
460
  n_bases: nCount,
446
461
  n_percent: Math.round((nCount / allBases) * 10000) / 100,
447
- quality_mean: Math.round(mean(summaryQualities) * 100) / 100,
448
- quality_q20_percent: Math.round((q20 / summaryQualities.length) * 10000) / 100,
449
- quality_q30_percent: Math.round((q30 / summaryQualities.length) * 10000) / 100,
462
+ quality_mean: Math.round((qualitySum / qualityCount) * 100) / 100,
463
+ quality_q20_percent: Math.round((q20 / qualityCount) * 10000) / 100,
464
+ quality_q30_percent: Math.round((q30 / qualityCount) * 10000) / 100,
450
465
  quality_tail_below_30: tailPosition(perBase, 30),
451
466
  quality_tail_below_20: tailPosition(perBase, 20),
452
467
  per_base: perBase,