mod-trace 0.2.0__tar.gz → 0.3.0__tar.gz

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 (43) hide show
  1. {mod_trace-0.2.0 → mod_trace-0.3.0}/.gitignore +2 -0
  2. {mod_trace-0.2.0 → mod_trace-0.3.0}/Cargo.lock +1 -1
  3. {mod_trace-0.2.0 → mod_trace-0.3.0}/Cargo.toml +1 -1
  4. {mod_trace-0.2.0 → mod_trace-0.3.0}/PKG-INFO +77 -57
  5. {mod_trace-0.2.0 → mod_trace-0.3.0}/README.md +76 -56
  6. mod_trace-0.3.0/docs/tensor-lab.md +66 -0
  7. {mod_trace-0.2.0 → mod_trace-0.3.0}/pyproject.toml +1 -1
  8. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/main.rs +310 -11
  9. mod_trace-0.2.0/.claude/scheduled_tasks.lock +0 -1
  10. mod_trace-0.2.0/mt-demo/make_onnx_pair.py +0 -27
  11. mod_trace-0.2.0/mt-demo/model_a.onnx +0 -0
  12. mod_trace-0.2.0/mt-demo/model_b.onnx +0 -0
  13. mod_trace-0.2.0/mt-demo/model_v1.onnx +0 -0
  14. mod_trace-0.2.0/mt-demo/model_v2.onnx +0 -0
  15. {mod_trace-0.2.0 → mod_trace-0.3.0}/.github/workflows/release.yml +0 -0
  16. {mod_trace-0.2.0 → mod_trace-0.3.0}/LICENSE +0 -0
  17. {mod_trace-0.2.0 → mod_trace-0.3.0}/benchmarks/tiny_pytorch.py +0 -0
  18. {mod_trace-0.2.0 → mod_trace-0.3.0}/docs/ARCHITECTURE.md +0 -0
  19. {mod_trace-0.2.0 → mod_trace-0.3.0}/docs/REAL_MODELS.md +0 -0
  20. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/broken_shape.json +0 -0
  21. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/lightgbm/README.md +0 -0
  22. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/lightgbm/clf_v1.txt +0 -0
  23. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/lightgbm/clf_v2.txt +0 -0
  24. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/lightgbm/generate_demo_models.py +0 -0
  25. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/make_sample_catboost.py +0 -0
  26. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/mlp.json +0 -0
  27. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/onnx/README.md +0 -0
  28. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/onnx/generate_demo_models.py +0 -0
  29. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/onnx/mlp_retrain_a.onnx +0 -0
  30. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/onnx/mlp_retrain_b.onnx +0 -0
  31. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/onnx/mlp_v1.onnx +0 -0
  32. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/onnx/mlp_v2.onnx +0 -0
  33. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/tiny_attention.json +0 -0
  34. {mod_trace-0.2.0 → mod_trace-0.3.0}/examples/tiny_attention_plan.json +0 -0
  35. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/catboost_deep_diff.py +0 -0
  36. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/catboost_explain.py +0 -0
  37. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/cbm.rs +0 -0
  38. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/demo.rs +0 -0
  39. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/explain.rs +0 -0
  40. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/lgbm.rs +0 -0
  41. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/model.rs +0 -0
  42. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/onnx.rs +0 -0
  43. {mod_trace-0.2.0 → mod_trace-0.3.0}/src/tensor.rs +0 -0
@@ -8,3 +8,5 @@ examples/*.cbm
8
8
  examples/*.onnx
9
9
  # but keep the synthetic ONNX demo models (committed as examples)
10
10
  !examples/onnx/
11
+ /mt-demo/
12
+ /.claude/
@@ -16,7 +16,7 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
16
16
 
17
17
  [[package]]
18
18
  name = "mod-trace"
19
- version = "0.2.0"
19
+ version = "0.3.0"
20
20
  dependencies = [
21
21
  "serde",
22
22
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "mod-trace"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  description = "Rust CLI for inspecting ML model artifacts without loading the framework"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mod-trace
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -27,18 +27,14 @@ What is inside this model file?
27
27
 
28
28
  It can inspect real artifacts such as CatBoost `.cbm` files, LightGBM `.txt`/`.lgb` text models, and ONNX `.onnx` graphs, then report structure, size, parameters, operator mix, rough inference cost, and changes between versions. CatBoost, LightGBM, and ONNX are all read natively — no Python, framework, or runtime needed (CatBoost `--deep` is the one optional exception).
29
29
 
30
- The secondary tensor lab keeps the original `EXPLAIN ANALYZE` idea for tiny neural-network plans:
31
-
32
- ```sql
33
- EXPLAIN ANALYZE SELECT ...
34
- ```
35
-
36
- becomes:
30
+ The most useful command is `explain-diff`, which says in plain English what changed between two model versions:
37
31
 
38
32
  ```sh
39
- mod-trace trace examples/tiny_attention_plan.json
33
+ mod-trace explain-diff old_model.onnx new_model.onnx
40
34
  ```
41
35
 
36
+ (A secondary "tensor lab" for handcrafted JSON plans lives in [docs/tensor-lab.md](docs/tensor-lab.md).)
37
+
42
38
  ## Core Commands
43
39
 
44
40
  ```sh
@@ -73,7 +69,10 @@ mod-trace explain model.onnx
73
69
  mod-trace diff old_model.cbm new_model.cbm
74
70
  mod-trace diff --json old_model.cbm new_model.cbm
75
71
  mod-trace diff --deep old_model.cbm new_model.cbm
72
+ mod-trace explain-diff old_model.onnx new_model.onnx
76
73
  mod-trace check --max-size-growth 20% --fail-on-feature-change old_model.cbm new_model.cbm
74
+ mod-trace inspect model.lgb # LightGBM (.lgb/.txt), read natively
75
+ mod-trace diff old.lgb new.lgb
77
76
  ```
78
77
 
79
78
  ## Why This Exists
@@ -160,10 +159,54 @@ cargo run -- check path/to/old_model.cbm path/to/new_model.cbm \
160
159
  cargo run -- check path/to/old_model.onnx path/to/new_model.onnx \
161
160
  --max-size-growth 20% \
162
161
  --max-ops-growth 25% \
162
+ --max-parameter-growth 30% \
163
163
  --fail-on-new-op
164
164
  ```
165
165
 
166
- `check` prints a short PASS/FAIL report and exits nonzero when a rule fails.
166
+ Check rules:
167
+
168
+ | Rule | Applies to | Fails when |
169
+ |------|------------|-----------|
170
+ | `--max-size-growth <pct>` | all | file size grows more than `<pct>` |
171
+ | `--max-parameter-growth <pct>` | ONNX | parameter count grows more than `<pct>` |
172
+ | `--max-ops-growth <pct>` | ONNX | estimated op count grows more than `<pct>` |
173
+ | `--fail-on-new-op` | ONNX | a new operator type appears |
174
+ | `--fail-on-feature-change` | CatBoost, LightGBM | feature names change |
175
+ | `--fail-on-training-config-change` | CatBoost, LightGBM | objective/learning rate/etc. change |
176
+
177
+ `check` prints a short PASS/FAIL report and exits nonzero when a rule fails. Any number of rules can be combined; any one failing fails the whole check.
178
+
179
+ ## Explain Diff
180
+
181
+ `explain-diff` is the plain-English version of `diff` — it reports what actually changed between two model versions, not just raw numbers:
182
+
183
+ ```sh
184
+ mod-trace explain-diff old_model.onnx new_model.onnx
185
+ ```
186
+
187
+ ```text
188
+ Model Change Explanation
189
+ ------------------------
190
+ Type: ONNX
191
+ Old: old_model.onnx
192
+ New: new_model.onnx
193
+
194
+ Architecture:
195
+ Attention layers: 12 -> 24
196
+ Hidden size: 768 -> 1024
197
+ Parameters: 110.0M -> 220.0M (+100%)
198
+ Nodes: 420 -> 820 (+95%)
199
+
200
+ Estimated inference cost (static op proxy): +94%
201
+
202
+ New operators introduced:
203
+ LayerNormalization
204
+
205
+ Summary:
206
+ Grew from ~12 to ~24 attention layers; parameters +100%, estimated cost +94%.
207
+ ```
208
+
209
+ Works for ONNX, CatBoost, and LightGBM (tree models report trees / leaves / learned-state instead of attention layers).
167
210
 
168
211
  ## CatBoost
169
212
 
@@ -457,61 +500,38 @@ cargo run -- inspect models/tiny-distilbert-base-cased/model_fixed.onnx
457
500
 
458
501
  Fixed shapes such as `[1, 8]` produce better numeric estimates than symbolic shapes such as `[batch, sequence]`.
459
502
 
460
- ## Tensor Lab
503
+ ### Exporting any PyTorch model to ONNX
461
504
 
462
- The original tensor-analysis MVP still exists as a lab for small handcrafted plans:
505
+ mod-trace does not read native PyTorch `.pt`/`.pth` files (those are Python pickles / TorchScript archives). The supported path is to export to ONNX, which is the usual serving format anyway. For a plain `nn.Module` the export is a single call:
463
506
 
464
- ```sh
465
- cargo run -- trace examples/tiny_attention_plan.json
466
- cargo run -- compare examples/tiny_attention_plan.json
467
- cargo run -- why examples/tiny_attention_plan.json
468
- cargo run -- validate examples/broken_shape.json
507
+ ```python
508
+ import torch
509
+
510
+ model.eval()
511
+ dummy = torch.randn(1, n_features) # one example input with the right shape
512
+ torch.onnx.export(
513
+ model,
514
+ dummy,
515
+ "model.onnx",
516
+ input_names=["input"],
517
+ output_names=["output"],
518
+ opset_version=18,
519
+ dynamo=True, # optional; the modern exporter
520
+ )
469
521
  ```
470
522
 
471
- Example plan:
472
-
473
- ```json
474
- {
475
- "layers": [
476
- {
477
- "type": "self_attention",
478
- "tokens": 3,
479
- "head_dim": 4,
480
- "value_dim": 4
481
- },
482
- {
483
- "type": "linear",
484
- "in": 4,
485
- "out": 2
486
- },
487
- {
488
- "type": "softmax"
489
- }
490
- ]
491
- }
523
+ ```sh
524
+ mod-trace inspect model.onnx
525
+ mod-trace diff old_model.onnx new_model.onnx
492
526
  ```
493
527
 
494
- `why` explains the attention cost:
528
+ Use fixed input shapes (e.g. `torch.randn(1, n_features)`) rather than dynamic axes for better cost estimates. Once exported, ONNX is read natively — no PyTorch, ONNX Runtime, or other framework is needed to inspect it.
495
529
 
496
- ```text
497
- Why is attention expensive?
498
- ---------------------------
499
- Attention layer: single_head_attention
500
- 432 ops
501
-
502
- Breakdown:
503
- Q projection 96 ops
504
- K projection 96 ops
505
- V projection 96 ops
506
- Q @ K^T 72 ops
507
- attention @ V 72 ops
508
-
509
- Explanation:
510
- Every token must compare itself against every other token.
511
- The score and value-mixing terms grow roughly with tokens^2.
512
- ```
530
+ ## Tensor Lab
513
531
 
514
- This is useful for demos and for explaining transformer internals, but it is no longer the primary product surface.
532
+ A secondary lab for explaining transformer internals on small handcrafted JSON
533
+ plans (`trace`, `compare`, `why`, `validate`, `quiz`, `demo`). It is not the
534
+ primary product surface. See [docs/tensor-lab.md](docs/tensor-lab.md).
515
535
 
516
536
  ## What It Does Not Do
517
537
 
@@ -10,18 +10,14 @@ What is inside this model file?
10
10
 
11
11
  It can inspect real artifacts such as CatBoost `.cbm` files, LightGBM `.txt`/`.lgb` text models, and ONNX `.onnx` graphs, then report structure, size, parameters, operator mix, rough inference cost, and changes between versions. CatBoost, LightGBM, and ONNX are all read natively — no Python, framework, or runtime needed (CatBoost `--deep` is the one optional exception).
12
12
 
13
- The secondary tensor lab keeps the original `EXPLAIN ANALYZE` idea for tiny neural-network plans:
14
-
15
- ```sql
16
- EXPLAIN ANALYZE SELECT ...
17
- ```
18
-
19
- becomes:
13
+ The most useful command is `explain-diff`, which says in plain English what changed between two model versions:
20
14
 
21
15
  ```sh
22
- mod-trace trace examples/tiny_attention_plan.json
16
+ mod-trace explain-diff old_model.onnx new_model.onnx
23
17
  ```
24
18
 
19
+ (A secondary "tensor lab" for handcrafted JSON plans lives in [docs/tensor-lab.md](docs/tensor-lab.md).)
20
+
25
21
  ## Core Commands
26
22
 
27
23
  ```sh
@@ -56,7 +52,10 @@ mod-trace explain model.onnx
56
52
  mod-trace diff old_model.cbm new_model.cbm
57
53
  mod-trace diff --json old_model.cbm new_model.cbm
58
54
  mod-trace diff --deep old_model.cbm new_model.cbm
55
+ mod-trace explain-diff old_model.onnx new_model.onnx
59
56
  mod-trace check --max-size-growth 20% --fail-on-feature-change old_model.cbm new_model.cbm
57
+ mod-trace inspect model.lgb # LightGBM (.lgb/.txt), read natively
58
+ mod-trace diff old.lgb new.lgb
60
59
  ```
61
60
 
62
61
  ## Why This Exists
@@ -143,10 +142,54 @@ cargo run -- check path/to/old_model.cbm path/to/new_model.cbm \
143
142
  cargo run -- check path/to/old_model.onnx path/to/new_model.onnx \
144
143
  --max-size-growth 20% \
145
144
  --max-ops-growth 25% \
145
+ --max-parameter-growth 30% \
146
146
  --fail-on-new-op
147
147
  ```
148
148
 
149
- `check` prints a short PASS/FAIL report and exits nonzero when a rule fails.
149
+ Check rules:
150
+
151
+ | Rule | Applies to | Fails when |
152
+ |------|------------|-----------|
153
+ | `--max-size-growth <pct>` | all | file size grows more than `<pct>` |
154
+ | `--max-parameter-growth <pct>` | ONNX | parameter count grows more than `<pct>` |
155
+ | `--max-ops-growth <pct>` | ONNX | estimated op count grows more than `<pct>` |
156
+ | `--fail-on-new-op` | ONNX | a new operator type appears |
157
+ | `--fail-on-feature-change` | CatBoost, LightGBM | feature names change |
158
+ | `--fail-on-training-config-change` | CatBoost, LightGBM | objective/learning rate/etc. change |
159
+
160
+ `check` prints a short PASS/FAIL report and exits nonzero when a rule fails. Any number of rules can be combined; any one failing fails the whole check.
161
+
162
+ ## Explain Diff
163
+
164
+ `explain-diff` is the plain-English version of `diff` — it reports what actually changed between two model versions, not just raw numbers:
165
+
166
+ ```sh
167
+ mod-trace explain-diff old_model.onnx new_model.onnx
168
+ ```
169
+
170
+ ```text
171
+ Model Change Explanation
172
+ ------------------------
173
+ Type: ONNX
174
+ Old: old_model.onnx
175
+ New: new_model.onnx
176
+
177
+ Architecture:
178
+ Attention layers: 12 -> 24
179
+ Hidden size: 768 -> 1024
180
+ Parameters: 110.0M -> 220.0M (+100%)
181
+ Nodes: 420 -> 820 (+95%)
182
+
183
+ Estimated inference cost (static op proxy): +94%
184
+
185
+ New operators introduced:
186
+ LayerNormalization
187
+
188
+ Summary:
189
+ Grew from ~12 to ~24 attention layers; parameters +100%, estimated cost +94%.
190
+ ```
191
+
192
+ Works for ONNX, CatBoost, and LightGBM (tree models report trees / leaves / learned-state instead of attention layers).
150
193
 
151
194
  ## CatBoost
152
195
 
@@ -440,61 +483,38 @@ cargo run -- inspect models/tiny-distilbert-base-cased/model_fixed.onnx
440
483
 
441
484
  Fixed shapes such as `[1, 8]` produce better numeric estimates than symbolic shapes such as `[batch, sequence]`.
442
485
 
443
- ## Tensor Lab
486
+ ### Exporting any PyTorch model to ONNX
444
487
 
445
- The original tensor-analysis MVP still exists as a lab for small handcrafted plans:
488
+ mod-trace does not read native PyTorch `.pt`/`.pth` files (those are Python pickles / TorchScript archives). The supported path is to export to ONNX, which is the usual serving format anyway. For a plain `nn.Module` the export is a single call:
446
489
 
447
- ```sh
448
- cargo run -- trace examples/tiny_attention_plan.json
449
- cargo run -- compare examples/tiny_attention_plan.json
450
- cargo run -- why examples/tiny_attention_plan.json
451
- cargo run -- validate examples/broken_shape.json
490
+ ```python
491
+ import torch
492
+
493
+ model.eval()
494
+ dummy = torch.randn(1, n_features) # one example input with the right shape
495
+ torch.onnx.export(
496
+ model,
497
+ dummy,
498
+ "model.onnx",
499
+ input_names=["input"],
500
+ output_names=["output"],
501
+ opset_version=18,
502
+ dynamo=True, # optional; the modern exporter
503
+ )
452
504
  ```
453
505
 
454
- Example plan:
455
-
456
- ```json
457
- {
458
- "layers": [
459
- {
460
- "type": "self_attention",
461
- "tokens": 3,
462
- "head_dim": 4,
463
- "value_dim": 4
464
- },
465
- {
466
- "type": "linear",
467
- "in": 4,
468
- "out": 2
469
- },
470
- {
471
- "type": "softmax"
472
- }
473
- ]
474
- }
506
+ ```sh
507
+ mod-trace inspect model.onnx
508
+ mod-trace diff old_model.onnx new_model.onnx
475
509
  ```
476
510
 
477
- `why` explains the attention cost:
511
+ Use fixed input shapes (e.g. `torch.randn(1, n_features)`) rather than dynamic axes for better cost estimates. Once exported, ONNX is read natively — no PyTorch, ONNX Runtime, or other framework is needed to inspect it.
478
512
 
479
- ```text
480
- Why is attention expensive?
481
- ---------------------------
482
- Attention layer: single_head_attention
483
- 432 ops
484
-
485
- Breakdown:
486
- Q projection 96 ops
487
- K projection 96 ops
488
- V projection 96 ops
489
- Q @ K^T 72 ops
490
- attention @ V 72 ops
491
-
492
- Explanation:
493
- Every token must compare itself against every other token.
494
- The score and value-mixing terms grow roughly with tokens^2.
495
- ```
513
+ ## Tensor Lab
496
514
 
497
- This is useful for demos and for explaining transformer internals, but it is no longer the primary product surface.
515
+ A secondary lab for explaining transformer internals on small handcrafted JSON
516
+ plans (`trace`, `compare`, `why`, `validate`, `quiz`, `demo`). It is not the
517
+ primary product surface. See [docs/tensor-lab.md](docs/tensor-lab.md).
498
518
 
499
519
  ## What It Does Not Do
500
520
 
@@ -0,0 +1,66 @@
1
+ # Tensor Lab (secondary)
2
+
3
+ The original tensor-analysis MVP. It is **not** the primary product surface —
4
+ mod-trace's main job is inspecting real model artifacts (CatBoost, LightGBM,
5
+ ONNX). The tensor lab is kept for demos and for explaining transformer
6
+ internals on small handcrafted JSON plans, in the spirit of `EXPLAIN ANALYZE`:
7
+
8
+ ```sql
9
+ EXPLAIN ANALYZE SELECT ...
10
+ ```
11
+
12
+ becomes:
13
+
14
+ ```sh
15
+ mod-trace trace examples/tiny_attention_plan.json
16
+ ```
17
+
18
+ ## Commands
19
+
20
+ ```sh
21
+ mod-trace trace examples/tiny_attention_plan.json # summarize a plan
22
+ mod-trace compare examples/tiny_attention_plan.json # compare layers
23
+ mod-trace why examples/tiny_attention_plan.json # explain attention cost
24
+ mod-trace validate examples/broken_shape.json # shape validation
25
+ mod-trace tensor-inspect examples/mlp.json
26
+ mod-trace quiz examples/mlp.json
27
+ mod-trace demo attention
28
+ mod-trace explain <topic> [--step] [--shapes|--memory|--math|--compare] [--quiz]
29
+ ```
30
+
31
+ (With a source checkout, replace `mod-trace` with `cargo run --`.)
32
+
33
+ ## Example plan
34
+
35
+ ```json
36
+ {
37
+ "layers": [
38
+ { "type": "self_attention", "tokens": 3, "head_dim": 4, "value_dim": 4 },
39
+ { "type": "linear", "in": 4, "out": 2 },
40
+ { "type": "softmax" }
41
+ ]
42
+ }
43
+ ```
44
+
45
+ ## `why` explains the attention cost
46
+
47
+ ```text
48
+ Why is attention expensive?
49
+ ---------------------------
50
+ Attention layer: single_head_attention
51
+ 432 ops
52
+
53
+ Breakdown:
54
+ Q projection 96 ops
55
+ K projection 96 ops
56
+ V projection 96 ops
57
+ Q @ K^T 72 ops
58
+ attention @ V 72 ops
59
+
60
+ Explanation:
61
+ Every token must compare itself against every other token.
62
+ The score and value-mixing terms grow roughly with tokens^2.
63
+ ```
64
+
65
+ This is useful for demos and for explaining transformer internals, but it is no
66
+ longer the primary product surface.
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "mod-trace"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Rust CLI for inspecting ML model artifacts without loading the framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -40,6 +40,7 @@ fn run() -> Result<(), String> {
40
40
  Some("check") => check_cmd(&args.rest),
41
41
  Some("doctor") => doctor_cmd(&args.rest),
42
42
  Some("explain") => explain_cmd(&args.rest),
43
+ Some("explain-diff") => explain_diff_cmd(&args.rest),
43
44
  Some("explain-model") | Some("walkthrough") => explain_model_cmd(&args.rest),
44
45
  Some("demo") => demo_cmd(&args.rest),
45
46
  Some("tour") => {
@@ -389,6 +390,7 @@ fn diff_cmd(args: &[String]) -> Result<(), String> {
389
390
  struct CheckOptions {
390
391
  max_size_growth_pct: Option<f64>,
391
392
  max_ops_growth_pct: Option<f64>,
393
+ max_parameter_growth_pct: Option<f64>,
392
394
  fail_on_feature_change: bool,
393
395
  fail_on_training_config_change: bool,
394
396
  fail_on_new_op: bool,
@@ -415,6 +417,13 @@ fn check_cmd(args: &[String]) -> Result<(), String> {
415
417
  options.max_ops_growth_pct = Some(parse_percent_threshold(value)?);
416
418
  i += 2;
417
419
  }
420
+ "--max-parameter-growth" => {
421
+ let value = args
422
+ .get(i + 1)
423
+ .ok_or_else(|| "--max-parameter-growth needs a percentage".to_string())?;
424
+ options.max_parameter_growth_pct = Some(parse_percent_threshold(value)?);
425
+ i += 2;
426
+ }
418
427
  "--fail-on-feature-change" => {
419
428
  options.fail_on_feature_change = true;
420
429
  i += 1;
@@ -462,13 +471,16 @@ fn check_cmd(args: &[String]) -> Result<(), String> {
462
471
  }
463
472
 
464
473
  fn check_usage() -> String {
465
- "usage: mod-trace check [--max-size-growth 20%] [--max-ops-growth 25%] [--fail-on-feature-change] [--fail-on-training-config-change] [--fail-on-new-op] <old-model> <new-model>".to_string()
474
+ "usage: mod-trace check [--max-size-growth 20%] [--max-ops-growth 25%] [--max-parameter-growth 30%] [--fail-on-feature-change] [--fail-on-training-config-change] [--fail-on-new-op] <old-model> <new-model>".to_string()
466
475
  }
467
476
 
468
477
  fn check_catboost(old_path: &str, new_path: &str, options: &CheckOptions) -> Result<(), String> {
469
478
  if options.max_ops_growth_pct.is_some() {
470
479
  return Err("--max-ops-growth is only supported for ONNX artifacts.".to_string());
471
480
  }
481
+ if options.max_parameter_growth_pct.is_some() {
482
+ return Err("--max-parameter-growth is only supported for ONNX artifacts.".to_string());
483
+ }
472
484
  if options.fail_on_new_op {
473
485
  return Err("--fail-on-new-op is only supported for ONNX artifacts.".to_string());
474
486
  }
@@ -545,6 +557,15 @@ fn check_onnx(old_path: &str, new_path: &str, options: &CheckOptions) -> Result<
545
557
  ));
546
558
  }
547
559
 
560
+ if let Some(max_pct) = options.max_parameter_growth_pct {
561
+ checks.push(growth_check(
562
+ "parameter growth",
563
+ old.total_parameter_values,
564
+ new.total_parameter_values,
565
+ max_pct,
566
+ ));
567
+ }
568
+
548
569
  if options.fail_on_new_op {
549
570
  let old_ops = old
550
571
  .op_counts
@@ -972,6 +993,268 @@ fn explain_onnx_cmd(path: &str) -> Result<(), String> {
972
993
  Ok(())
973
994
  }
974
995
 
996
+ fn explain_diff_cmd(args: &[String]) -> Result<(), String> {
997
+ let paths = args
998
+ .iter()
999
+ .filter(|arg| !arg.starts_with("--"))
1000
+ .collect::<Vec<_>>();
1001
+ if paths.len() != 2 {
1002
+ return Err(
1003
+ "usage: mod-trace explain-diff <old-model> <new-model> (.onnx, .cbm, or .lgb/.txt)"
1004
+ .to_string(),
1005
+ );
1006
+ }
1007
+ let (old, new) = (paths[0].as_str(), paths[1].as_str());
1008
+ match (artifact_kind(old), artifact_kind(new)) {
1009
+ (ArtifactKind::Onnx, ArtifactKind::Onnx) => explain_diff_onnx(old, new),
1010
+ (ArtifactKind::LightGbm, ArtifactKind::LightGbm) => explain_diff_lgbm(old, new),
1011
+ (ArtifactKind::CatBoost, ArtifactKind::CatBoost) => explain_diff_catboost(old, new),
1012
+ (left, right) => Err(format!(
1013
+ "explain-diff needs two artifacts of the same supported type (.onnx, .cbm, .lgb): {} vs {}",
1014
+ left.label(),
1015
+ right.label()
1016
+ )),
1017
+ }
1018
+ }
1019
+
1020
+ fn opt_num(value: Option<usize>) -> String {
1021
+ value
1022
+ .map(|value| value.to_string())
1023
+ .unwrap_or_else(|| "unknown".to_string())
1024
+ }
1025
+
1026
+ fn format_count_human(count: usize) -> String {
1027
+ let value = count as f64;
1028
+ if value >= 1e9 {
1029
+ format!("{:.1}B", value / 1e9)
1030
+ } else if value >= 1e6 {
1031
+ format!("{:.1}M", value / 1e6)
1032
+ } else if value >= 1e3 {
1033
+ format!("{:.1}K", value / 1e3)
1034
+ } else {
1035
+ count.to_string()
1036
+ }
1037
+ }
1038
+
1039
+ fn growth_label(old: usize, new: usize) -> String {
1040
+ match growth_percent(old, new) {
1041
+ Some(pct) if pct.abs() < 0.005 => "same".to_string(),
1042
+ Some(pct) => format!("{pct:+.0}%"),
1043
+ None => {
1044
+ if new == old {
1045
+ "same".to_string()
1046
+ } else {
1047
+ "n/a (from zero)".to_string()
1048
+ }
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ fn explain_diff_onnx(old_path: &str, new_path: &str) -> Result<(), String> {
1054
+ let old = onnx::inspect(old_path)?;
1055
+ let new = onnx::inspect(new_path)?;
1056
+
1057
+ let old_layers = estimate_transformer_layers(
1058
+ op_count(&old, "Softmax"),
1059
+ op_count(&old, "LayerNormalization"),
1060
+ op_count(&old, "Attention"),
1061
+ );
1062
+ let new_layers = estimate_transformer_layers(
1063
+ op_count(&new, "Softmax"),
1064
+ op_count(&new, "LayerNormalization"),
1065
+ op_count(&new, "Attention"),
1066
+ );
1067
+ let old_hidden = estimate_hidden_size(&old);
1068
+ let new_hidden = estimate_hidden_size(&new);
1069
+
1070
+ let old_ops = old
1071
+ .op_counts
1072
+ .iter()
1073
+ .map(|(name, _)| name)
1074
+ .collect::<BTreeSet<_>>();
1075
+ let new_ops = new
1076
+ .op_counts
1077
+ .iter()
1078
+ .map(|(name, _)| name)
1079
+ .collect::<BTreeSet<_>>();
1080
+ let added = new_ops
1081
+ .difference(&old_ops)
1082
+ .map(|name| (*name).clone())
1083
+ .collect::<Vec<_>>();
1084
+ let removed = old_ops
1085
+ .difference(&new_ops)
1086
+ .map(|name| (*name).clone())
1087
+ .collect::<Vec<_>>();
1088
+
1089
+ println!("Model Change Explanation");
1090
+ println!("------------------------");
1091
+ println!("Type: ONNX");
1092
+ println!("Old: {}", old.path);
1093
+ println!("New: {}", new.path);
1094
+ println!();
1095
+ println!("Architecture:");
1096
+ println!(
1097
+ " Attention layers: {} -> {}",
1098
+ opt_num(old_layers),
1099
+ opt_num(new_layers)
1100
+ );
1101
+ println!(
1102
+ " Hidden size: {} -> {}",
1103
+ opt_num(old_hidden),
1104
+ opt_num(new_hidden)
1105
+ );
1106
+ println!(
1107
+ " Parameters: {} -> {} ({})",
1108
+ format_count_human(old.total_parameter_values),
1109
+ format_count_human(new.total_parameter_values),
1110
+ growth_label(old.total_parameter_values, new.total_parameter_values)
1111
+ );
1112
+ println!(
1113
+ " Nodes: {} -> {} ({})",
1114
+ old.nodes.len(),
1115
+ new.nodes.len(),
1116
+ growth_label(old.nodes.len(), new.nodes.len())
1117
+ );
1118
+ println!();
1119
+ println!(
1120
+ "Estimated inference cost (static op proxy): {}",
1121
+ growth_label(old.estimated_ops, new.estimated_ops)
1122
+ );
1123
+ println!();
1124
+ if added.is_empty() {
1125
+ println!("New operators introduced: none");
1126
+ } else {
1127
+ println!("New operators introduced:");
1128
+ for op in &added {
1129
+ println!(" {op}");
1130
+ }
1131
+ }
1132
+ if !removed.is_empty() {
1133
+ println!("Operators removed:");
1134
+ for op in &removed {
1135
+ println!(" {op}");
1136
+ }
1137
+ }
1138
+ println!();
1139
+ println!("Summary:");
1140
+ match (old_layers, new_layers) {
1141
+ (Some(a), Some(b)) if a != b => println!(
1142
+ " Grew from ~{a} to ~{b} attention layers; parameters {}, estimated cost {}.",
1143
+ growth_label(old.total_parameter_values, new.total_parameter_values),
1144
+ growth_label(old.estimated_ops, new.estimated_ops)
1145
+ ),
1146
+ _ => println!(
1147
+ " Parameters {}, estimated cost {}.",
1148
+ growth_label(old.total_parameter_values, new.total_parameter_values),
1149
+ growth_label(old.estimated_ops, new.estimated_ops)
1150
+ ),
1151
+ }
1152
+ println!();
1153
+ println!(
1154
+ "Note: static heuristic over ONNX ops and shapes; cost is an op-count proxy, not measured latency."
1155
+ );
1156
+
1157
+ Ok(())
1158
+ }
1159
+
1160
+ fn explain_diff_lgbm(old_path: &str, new_path: &str) -> Result<(), String> {
1161
+ let old = lgbm::inspect(old_path)?;
1162
+ let new = lgbm::inspect(new_path)?;
1163
+
1164
+ println!("Model Change Explanation");
1165
+ println!("------------------------");
1166
+ println!("Type: LightGBM");
1167
+ println!("Old: {}", old.path);
1168
+ println!("New: {}", new.path);
1169
+ println!();
1170
+ println!("Architecture:");
1171
+ println!(
1172
+ " Trees: {} -> {} ({})",
1173
+ old.num_trees,
1174
+ new.num_trees,
1175
+ growth_label(old.num_trees as usize, new.num_trees as usize)
1176
+ );
1177
+ println!(
1178
+ " Leaves / tree: {} -> {}",
1179
+ opt_num(old.num_leaves.map(|value| value as usize)),
1180
+ opt_num(new.num_leaves.map(|value| value as usize))
1181
+ );
1182
+ println!(
1183
+ " Features: {} -> {}",
1184
+ opt_num(old.num_features.map(|value| value as usize)),
1185
+ opt_num(new.num_features.map(|value| value as usize))
1186
+ );
1187
+ println!(
1188
+ " Objective: {} -> {}",
1189
+ old.objective.as_deref().unwrap_or("unknown"),
1190
+ new.objective.as_deref().unwrap_or("unknown")
1191
+ );
1192
+ println!();
1193
+ match (old.estimated_leaf_values(), new.estimated_leaf_values()) {
1194
+ (Some(o), Some(n)) => println!(
1195
+ "Estimated leaf-slot growth: {}",
1196
+ growth_label(o as usize, n as usize)
1197
+ ),
1198
+ _ => println!("Estimated leaf-slot growth: unknown"),
1199
+ }
1200
+ println!();
1201
+ if old.learned_state_fingerprint != new.learned_state_fingerprint {
1202
+ println!("Learned state: CHANGED (a real retrain - leaf values differ)");
1203
+ } else {
1204
+ println!("Learned state: unchanged (identical leaf values)");
1205
+ }
1206
+ println!();
1207
+ println!("Note: parsed natively from the LightGBM text model.");
1208
+
1209
+ Ok(())
1210
+ }
1211
+
1212
+ fn explain_diff_catboost(old_path: &str, new_path: &str) -> Result<(), String> {
1213
+ let old = cbm::inspect(old_path)?;
1214
+ let new = cbm::inspect(new_path)?;
1215
+
1216
+ println!("Model Change Explanation");
1217
+ println!("------------------------");
1218
+ println!("Type: CatBoost");
1219
+ println!("Old: {}", old.path);
1220
+ println!("New: {}", new.path);
1221
+ println!();
1222
+ println!("Architecture:");
1223
+ println!(
1224
+ " Trees: {} -> {}",
1225
+ opt_num(old.iterations.map(|value| value as usize)),
1226
+ opt_num(new.iterations.map(|value| value as usize))
1227
+ );
1228
+ println!(
1229
+ " Depth: {} -> {}",
1230
+ opt_num(old.depth.map(|value| value as usize)),
1231
+ opt_num(new.depth.map(|value| value as usize))
1232
+ );
1233
+ println!(
1234
+ " Features (recovered): {} -> {}",
1235
+ old.feature_candidates.len(),
1236
+ new.feature_candidates.len()
1237
+ );
1238
+ println!();
1239
+ match (old.estimated_leaf_values(), new.estimated_leaf_values()) {
1240
+ (Some(o), Some(n)) => println!(
1241
+ "Estimated leaf-slot growth: {}",
1242
+ growth_label(o as usize, n as usize)
1243
+ ),
1244
+ _ => println!("Estimated leaf-slot growth: unknown"),
1245
+ }
1246
+ println!();
1247
+ if old.learned_state_fingerprint != new.learned_state_fingerprint {
1248
+ println!("Learned state: CHANGED (split borders / leaf values differ)");
1249
+ } else {
1250
+ println!("Learned state: unchanged");
1251
+ }
1252
+ println!();
1253
+ println!("Note: CatBoost internals are summarized; run `diff --deep` for exact split/leaf changes.");
1254
+
1255
+ Ok(())
1256
+ }
1257
+
975
1258
  fn op_count(report: &onnx::OnnxReport, op_type: &str) -> usize {
976
1259
  report
977
1260
  .op_counts
@@ -2549,6 +2832,9 @@ fn check_lgbm(old_path: &str, new_path: &str, options: &CheckOptions) -> Result<
2549
2832
  if options.max_ops_growth_pct.is_some() {
2550
2833
  return Err("--max-ops-growth is only supported for ONNX artifacts.".to_string());
2551
2834
  }
2835
+ if options.max_parameter_growth_pct.is_some() {
2836
+ return Err("--max-parameter-growth is only supported for ONNX artifacts.".to_string());
2837
+ }
2552
2838
  if options.fail_on_new_op {
2553
2839
  return Err("--fail-on-new-op is only supported for ONNX artifacts.".to_string());
2554
2840
  }
@@ -3090,19 +3376,16 @@ fn print_help() {
3090
3376
  Core usage:\n \
3091
3377
  mod-trace doctor [--json]\n \
3092
3378
  mod-trace inspect [--deep] [--json] [--limit 20] <model.cbm|model.lgb|model.onnx|model.json>\n \
3093
- mod-trace diff [--deep] [--json] <old-model> <new-model> (.cbm, .lgb/.txt LightGBM, or .onnx)\n\n\
3094
- mod-trace check [--max-size-growth 20%] [--max-ops-growth 25%] [--fail-on-feature-change] [--fail-on-training-config-change] [--fail-on-new-op] <old-model> <new-model>\n\n\
3379
+ mod-trace diff [--deep] [--json] <old-model> <new-model> (.cbm, .lgb/.txt LightGBM, or .onnx)\n \
3380
+ mod-trace explain-diff <old-model> <new-model> (plain-English what changed: layers, params, cost, new ops)\n\n\
3381
+ mod-trace check [--max-size-growth 20%] [--max-ops-growth 25%] [--max-parameter-growth 30%] [--fail-on-feature-change] [--fail-on-training-config-change] [--fail-on-new-op] <old-model> <new-model>\n\n\
3095
3382
  Artifact inspectors:\n \
3096
3383
  mod-trace catboost [--deep] [--json] [--limit 20] <model.cbm> [more.cbm...]\n \
3097
3384
  mod-trace lightgbm [--json] [--limit 20] <model.lgb|model.txt> [more...]\n \
3098
- mod-trace onnx [--json] [--limit 20] <model.onnx>\n\n\
3099
- Tensor lab:\n \
3100
- mod-trace trace <model.json>\n \
3101
- mod-trace compare <model.json>\n \
3102
- mod-trace why <model.json>\n \
3103
- mod-trace validate <model.json>\n \
3104
- mod-trace tensor-inspect <model.json>\n \
3105
- mod-trace explain <model.onnx|model.cbm>\n \
3385
+ mod-trace onnx [--json] [--limit 20] <model.onnx>\n \
3386
+ mod-trace explain <model.onnx|model.cbm>\n\n\
3387
+ Tensor lab (secondary; see docs/tensor-lab.md):\n \
3388
+ mod-trace trace|compare|why|validate|tensor-inspect <model.json>\n \
3106
3389
  mod-trace explain <topic> [--step] [--shapes|--memory|--math|--compare] [--quiz]\n\n\
3107
3390
  Examples:\n \
3108
3391
  mod-trace doctor\n \
@@ -3205,6 +3488,22 @@ fn format_hex(value: u64) -> String {
3205
3488
  mod tests {
3206
3489
  use super::*;
3207
3490
 
3491
+ #[test]
3492
+ fn format_count_human_scales_units() {
3493
+ assert_eq!(format_count_human(676), "676");
3494
+ assert_eq!(format_count_human(19_204), "19.2K");
3495
+ assert_eq!(format_count_human(110_000_000), "110.0M");
3496
+ assert_eq!(format_count_human(2_200_000_000), "2.2B");
3497
+ }
3498
+
3499
+ #[test]
3500
+ fn growth_label_formats_changes() {
3501
+ assert_eq!(growth_label(100, 200), "+100%");
3502
+ assert_eq!(growth_label(200, 100), "-50%");
3503
+ assert_eq!(growth_label(100, 100), "same");
3504
+ assert_eq!(growth_label(0, 5), "n/a (from zero)");
3505
+ }
3506
+
3208
3507
  #[test]
3209
3508
  fn estimates_transformer_layers_from_attention_signals() {
3210
3509
  assert_eq!(estimate_transformer_layers(12, 24, 0), Some(12));
@@ -1 +0,0 @@
1
- {"sessionId":"a6f7a6f9-0897-4154-80fe-a91a5084903f","pid":34928,"procStart":"Thu Jun 4 15:37:09 2026","acquiredAt":1780606199550}
@@ -1,27 +0,0 @@
1
- import numpy as np, onnx, os
2
- from onnx import helper, TensorProto, numpy_helper
3
-
4
- def build(path, seed, producer_ver, hidden=64, classes=4):
5
- rng = np.random.default_rng(seed)
6
- def arr(name, *shape):
7
- return numpy_helper.from_array(rng.standard_normal(shape).astype(np.float32) * 0.1, name)
8
- inp = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 16])
9
- out = helper.make_tensor_value_info("logits", TensorProto.FLOAT, [1, classes])
10
- inits = [arr("W1", 16, hidden), arr("b1", hidden), arr("W2", hidden, classes), arr("b2", classes)]
11
- nodes = [
12
- helper.make_node("Gemm", ["input", "W1", "b1"], ["h1"]),
13
- helper.make_node("Relu", ["h1"], ["a1"]),
14
- helper.make_node("Gemm", ["a1", "W2", "b2"], ["z"]),
15
- helper.make_node("Identity", ["z"], ["logits"]),
16
- ]
17
- g = helper.make_graph(nodes, "classifier", [inp], [out], initializer=inits)
18
- m = helper.make_model(g, producer_name="demo-trainer", producer_version=producer_ver,
19
- opset_imports=[helper.make_opsetid("", 13)])
20
- onnx.checker.check_model(m)
21
- onnx.save(m, path)
22
- print("wrote", path)
23
-
24
- os.makedirs("/tmp/mt-demo", exist_ok=True)
25
- # same architecture & shapes; only the learned weights (and producer version) differ -> a "retrain"
26
- build("/tmp/mt-demo/model_a.onnx", seed=1, producer_ver="1.0")
27
- build("/tmp/mt-demo/model_b.onnx", seed=2, producer_ver="1.1")
Binary file
Binary file
Binary file
Binary file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes