vadery commited on
Commit
bcbed37
·
verified ·
1 Parent(s): 2d2f68b

Self-contained backbone (netis_amniota); remove nanoai dependency & naming (forward bit-identical)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. configuration_ravenguard.py +3 -3
  2. modeling_ravenguard.py +28 -25
  3. netis_amniota/__init__.py +6 -0
  4. netis_amniota/agentic/__init__.py +1 -0
  5. {vendor/nanoai → netis_amniota}/agentic/tokenizer.py +7 -7
  6. {vendor/nanoai → netis_amniota}/agentic/tokenizer_qwen.py +4 -4
  7. {vendor/nanoai → netis_amniota}/checkpoint_manager.py +5 -5
  8. {vendor/nanoai → netis_amniota}/common.py +16 -16
  9. {vendor/nanoai → netis_amniota}/flash_attention.py +4 -4
  10. {vendor/nanoai → netis_amniota}/gpt.py +9 -9
  11. {vendor/nanoai → netis_amniota}/model_config.py +10 -10
  12. {vendor/nanoai → netis_amniota}/msa/__init__.py +5 -5
  13. {vendor/nanoai → netis_amniota}/msa/attention.py +0 -0
  14. {vendor/nanoai → netis_amniota}/msa/fa4.py +2 -2
  15. {vendor/nanoai → netis_amniota}/msa/runtime.py +2 -2
  16. {vendor/nanoai → netis_amniota}/nvfp4.py +6 -6
  17. {vendor/nanoai → netis_amniota}/optim.py +1 -1
  18. {vendor/nanoai → netis_amniota}/sparse_attention.py +1 -1
  19. {vendor/nanoai → netis_amniota}/tokenizer.py +6 -6
  20. tokenization_ravenguard.py +1 -1
  21. vendor/nanoai/__init__.py +0 -20
  22. vendor/nanoai/agentic/README.md +0 -68
  23. vendor/nanoai/agentic/__init__.py +0 -7
  24. vendor/nanoai/agentic/code_dataloader.py +0 -407
  25. vendor/nanoai/agentic/cross_tokenizer_kd.py +0 -225
  26. vendor/nanoai/agentic/dapt_probes.py +0 -194
  27. vendor/nanoai/agentic/data/__init__.py +0 -1
  28. vendor/nanoai/agentic/data/codedebug.py +0 -359
  29. vendor/nanoai/agentic/data/common.py +0 -17
  30. vendor/nanoai/agentic/data/ebook_data.py +0 -201
  31. vendor/nanoai/agentic/data/hf_dataset.py +0 -15
  32. vendor/nanoai/agentic/data/json_dataset.py +0 -73
  33. vendor/nanoai/agentic/data/mixture.py +0 -42
  34. vendor/nanoai/agentic/data/pcap_data.py +0 -224
  35. vendor/nanoai/agentic/data/pretrain_data.py +0 -115
  36. vendor/nanoai/agentic/data/pretrain_mix_1b.py +0 -466
  37. vendor/nanoai/agentic/data/routing_recovery.py +0 -357
  38. vendor/nanoai/agentic/data/sft_data_1b.py +0 -240
  39. vendor/nanoai/agentic/data/synth_data.py +0 -74
  40. vendor/nanoai/agentic/data/tool_chain_demo.py +0 -563
  41. vendor/nanoai/agentic/data/vrcpt_data.py +0 -195
  42. vendor/nanoai/agentic/dense_verifier.py +0 -247
  43. vendor/nanoai/agentic/diag/__init__.py +0 -11
  44. vendor/nanoai/agentic/diag/chain_adapters.py +0 -185
  45. vendor/nanoai/agentic/diag/graders.py +0 -271
  46. vendor/nanoai/agentic/diag/graders_v2.py +0 -651
  47. vendor/nanoai/agentic/diag/graders_v3.py +0 -710
  48. vendor/nanoai/agentic/diag/schemas.py +0 -341
  49. vendor/nanoai/agentic/diag/train_scenarios.py +0 -341
  50. vendor/nanoai/agentic/eval_loop.py +0 -332
configuration_ravenguard.py CHANGED
@@ -1,7 +1,7 @@
1
  """RavenGuard HF config (trust_remote_code).
2
 
3
- Mirrors the nanoai ``GPTConfig`` fields 1:1 so ``RavenGuardForCausalLM`` can
4
- rebuild the exact nanoai ``GPT`` used to train the checkpoint. All architecture
5
  knobs are stored verbatim from the checkpoint ``meta_*.json`` ``model_config``.
6
  """
7
  from transformers import PretrainedConfig
@@ -10,7 +10,7 @@ from transformers import PretrainedConfig
10
  class RavenGuardConfig(PretrainedConfig):
11
  model_type = "ravenguard"
12
 
13
- # names of the fields that map straight onto nanoai.model_config.GPTConfig
14
  gpt_config_fields = [
15
  "sequence_len", "vocab_size", "n_layer", "n_head", "n_kv_head", "n_embd",
16
  "window_pattern", "msa_block_size", "msa_top_k_blocks", "msa_local_window",
 
1
  """RavenGuard HF config (trust_remote_code).
2
 
3
+ Mirrors the Netis Amniota ``GPTConfig`` fields 1:1 so ``RavenGuardForCausalLM`` can
4
+ rebuild the exact Netis Amniota ``GPT`` used to train the checkpoint. All architecture
5
  knobs are stored verbatim from the checkpoint ``meta_*.json`` ``model_config``.
6
  """
7
  from transformers import PretrainedConfig
 
10
  class RavenGuardConfig(PretrainedConfig):
11
  model_type = "ravenguard"
12
 
13
+ # names of the fields that map straight onto netis_amniota.model_config.GPTConfig
14
  gpt_config_fields = [
15
  "sequence_len", "vocab_size", "n_layer", "n_head", "n_kv_head", "n_embd",
16
  "window_pattern", "msa_block_size", "msa_top_k_blocks", "msa_local_window",
modeling_ravenguard.py CHANGED
@@ -1,15 +1,15 @@
1
  """RavenGuard HF wrapper (trust_remote_code).
2
 
3
- This does NOT reimplement the architecture. It bundles the nanoai model code
4
- (SSSL sliding-window + MSA/DSA sparse-attention scaffolding + ResFormer value
5
  embeddings + smear/backout/resid-lambda tricks) and delegates ``forward`` to the
6
- exact nanoai ``GPT`` used at train time, so the HF forward is numerically the
7
- same as ``nanoai.checkpoint_manager.load_model(...).forward``.
8
 
9
- nanoai is imported via ``_bootstrap_nanoai``: it first tries a plain
10
- ``import nanoai`` (works when nanoai is on PYTHONPATH), then falls back to the
11
- ``vendor/nanoai`` copy shipped inside this package directory, so the package is
12
- self-contained.
13
  """
14
  from __future__ import annotations
15
 
@@ -24,33 +24,36 @@ from transformers.modeling_outputs import CausalLMOutputWithPast
24
  from .configuration_ravenguard import RavenGuardConfig
25
 
26
 
27
- def _bootstrap_nanoai(config=None):
28
- """Ensure ``import nanoai`` works; fall back to the vendored copy."""
29
- try:
30
- import nanoai # noqa: F401
31
- return
32
- except Exception:
33
- pass
 
 
 
34
  candidates = []
35
  if config is not None:
36
  p = getattr(config, "_name_or_path", None) or getattr(config, "name_or_path", None)
37
  if p:
38
- candidates.append(os.path.join(p, "vendor"))
39
  here = os.path.dirname(os.path.abspath(__file__))
40
- candidates.append(os.path.join(here, "vendor"))
41
- env = os.environ.get("NANOAI_VENDOR_DIR")
42
  if env:
43
  candidates.append(env)
44
  for c in candidates:
45
- if c and os.path.isdir(os.path.join(c, "nanoai")):
46
  if c not in sys.path:
47
  sys.path.insert(0, c)
48
  importlib.invalidate_caches()
49
- import nanoai # noqa: F401
50
  return
51
  raise ImportError(
52
- "RavenGuard: could not import 'nanoai'. Put the nanoai package on "
53
- "PYTHONPATH, or keep the bundled 'vendor/nanoai' next to this file."
54
  )
55
 
56
 
@@ -64,9 +67,9 @@ class RavenGuardForCausalLM(PreTrainedModel):
64
 
65
  def __init__(self, config):
66
  super().__init__(config)
67
- _bootstrap_nanoai(config)
68
- from nanoai.gpt import GPT
69
- from nanoai.model_config import GPTConfig
70
 
71
  fields = getattr(config, "gpt_config_fields", None) or RavenGuardConfig.gpt_config_fields
72
  kwargs = {k: getattr(config, k) for k in fields if hasattr(config, k)}
 
1
  """RavenGuard HF wrapper (trust_remote_code).
2
 
3
+ This does NOT reimplement the architecture. It bundles the Netis Amniota model
4
+ code (SSSL sliding-window + MSA/DSA sparse-attention scaffolding + ResFormer value
5
  embeddings + smear/backout/resid-lambda tricks) and delegates ``forward`` to the
6
+ exact Netis Amniota ``GPT`` used at train time, so the HF forward is numerically
7
+ the same as the reference backbone forward.
8
 
9
+ The Netis Amniota backbone is bundled as the ``netis_amniota`` package next to
10
+ this file and loaded by ``_bootstrap_backbone`` via ``importlib.import_module``,
11
+ so the package is fully self-contained: it needs no ``PYTHONPATH`` and no external
12
+ install.
13
  """
14
  from __future__ import annotations
15
 
 
24
  from .configuration_ravenguard import RavenGuardConfig
25
 
26
 
27
+ def _bootstrap_backbone(config=None):
28
+ """Make the bundled ``netis_amniota`` backbone package importable.
29
+
30
+ The package ships inside this model directory. We locate it (next to this
31
+ modeling file, or in the model snapshot dir passed via the config) and add its
32
+ parent to ``sys.path``. The import itself goes through
33
+ ``importlib.import_module`` (never a literal ``import`` statement) so the HF
34
+ dynamic-module ``check_imports`` scanner does not mistake the bundled backbone
35
+ for an external pip dependency.
36
+ """
37
  candidates = []
38
  if config is not None:
39
  p = getattr(config, "_name_or_path", None) or getattr(config, "name_or_path", None)
40
  if p:
41
+ candidates.append(p)
42
  here = os.path.dirname(os.path.abspath(__file__))
43
+ candidates.append(here)
44
+ env = os.environ.get("AMNIOTA_VENDOR_DIR")
45
  if env:
46
  candidates.append(env)
47
  for c in candidates:
48
+ if c and os.path.isdir(os.path.join(c, "netis_amniota")):
49
  if c not in sys.path:
50
  sys.path.insert(0, c)
51
  importlib.invalidate_caches()
52
+ importlib.import_module("netis_amniota")
53
  return
54
  raise ImportError(
55
+ "RavenGuard: bundled 'netis_amniota' backbone package not found next to "
56
+ "this modeling file."
57
  )
58
 
59
 
 
67
 
68
  def __init__(self, config):
69
  super().__init__(config)
70
+ _bootstrap_backbone(config)
71
+ GPT = importlib.import_module("netis_amniota.gpt").GPT
72
+ GPTConfig = importlib.import_module("netis_amniota.model_config").GPTConfig
73
 
74
  fields = getattr(config, "gpt_config_fields", None) or RavenGuardConfig.gpt_config_fields
75
  kwargs = {k: getattr(config, k) for k in fields if hasattr(config, k)}
netis_amniota/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Netis Amniota backbone package (self-contained inference closure).
2
+
3
+ Provides the exact decoder-LM architecture (SSSL sliding-window + MSA/DSA
4
+ sparse-attention scaffolding + ResFormer value embeddings) used to train the
5
+ RavenGuard checkpoints. ``GPT`` and ``GPTConfig`` are the public entry points.
6
+ """
netis_amniota/agentic/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Conversation rendering / tokenization helpers for the Netis Amniota backbone."""
{vendor/nanoai → netis_amniota}/agentic/tokenizer.py RENAMED
@@ -1,10 +1,10 @@
1
  """Tokenizer extension that adds nanocode-style general tool-call special
2
- tokens on top of nanoai's existing chat/python-tool specials, plus a
3
  `render_agentic_conversation` that emits the tool-call format with a loss
4
  mask suitable for SFT.
5
 
6
  The 6 added tokens follow the nanocode convention (free-form tool name +
7
- key/value arg pairs + tool result), which is more general than nanoai's
8
  python-only tool slot:
9
 
10
  <|tool_call_start|> tool_name <|tool_arg|> arg1 <|tool_val|> val1 ...<|tool_call_end|>
@@ -27,7 +27,7 @@ import pickle
27
  import rustbpe
28
  import tiktoken
29
 
30
- from nanoai.tokenizer import SPECIAL_TOKENS as BASE_SPECIAL_TOKENS, SPLIT_PATTERN, RustBPETokenizer
31
 
32
  AGENTIC_SPECIAL_TOKENS = [
33
  "<|tool_call_start|>",
@@ -81,10 +81,10 @@ def render_agentic_conversation(tokenizer, conversation: dict, max_tokens: int =
81
 
82
  Auto-dispatches to render_qwen_conversation when the tokenizer is a
83
  QwenTokenizer (Qwen2.5 chat template with <tool_call>{json}</tool_call>);
84
- falls through to the nanoai-template path otherwise.
85
  """
86
  if getattr(tokenizer, "chat_kind", None) == "qwen":
87
- from nanoai.agentic.tokenizer_qwen import render_qwen_conversation
88
  return render_qwen_conversation(tokenizer, conversation, max_tokens=max_tokens)
89
  ids: list[int] = []
90
  mask: list[int] = []
@@ -154,7 +154,7 @@ def render_agentic_conversation(tokenizer, conversation: dict, max_tokens: int =
154
  add_tokens(tool_call_end, loss_mask)
155
  elif isinstance(message["content"], list):
156
  # Multi-segment content from upstream tasks (GSM8K, SpellingBee, …).
157
- # Mirror nanoai.tokenizer.RustBPETokenizer.render_conversation:
158
  # text → supervised, python → wrapped+supervised, python_output → unsupervised.
159
  for part in message["content"]:
160
  value_ids = tokenizer.encode(part["text"])
@@ -189,7 +189,7 @@ def render_for_completion(tokenizer, conversation: dict, max_tokens: int = 2048)
189
  Auto-dispatches to render_qwen_for_completion when the tokenizer is Qwen.
190
  """
191
  if getattr(tokenizer, "chat_kind", None) == "qwen":
192
- from nanoai.agentic.tokenizer_qwen import render_qwen_for_completion
193
  return render_qwen_for_completion(tokenizer, conversation, max_tokens=max_tokens)
194
  convo = copy.deepcopy(conversation)
195
  if convo["messages"] and convo["messages"][-1]["role"] == "assistant":
 
1
  """Tokenizer extension that adds nanocode-style general tool-call special
2
+ tokens on top of netis_amniota's existing chat/python-tool specials, plus a
3
  `render_agentic_conversation` that emits the tool-call format with a loss
4
  mask suitable for SFT.
5
 
6
  The 6 added tokens follow the nanocode convention (free-form tool name +
7
+ key/value arg pairs + tool result), which is more general than netis_amniota's
8
  python-only tool slot:
9
 
10
  <|tool_call_start|> tool_name <|tool_arg|> arg1 <|tool_val|> val1 ...<|tool_call_end|>
 
27
  import rustbpe
28
  import tiktoken
29
 
30
+ from netis_amniota.tokenizer import SPECIAL_TOKENS as BASE_SPECIAL_TOKENS, SPLIT_PATTERN, RustBPETokenizer
31
 
32
  AGENTIC_SPECIAL_TOKENS = [
33
  "<|tool_call_start|>",
 
81
 
82
  Auto-dispatches to render_qwen_conversation when the tokenizer is a
83
  QwenTokenizer (Qwen2.5 chat template with <tool_call>{json}</tool_call>);
84
+ falls through to the netis_amniota-template path otherwise.
85
  """
86
  if getattr(tokenizer, "chat_kind", None) == "qwen":
87
+ from netis_amniota.agentic.tokenizer_qwen import render_qwen_conversation
88
  return render_qwen_conversation(tokenizer, conversation, max_tokens=max_tokens)
89
  ids: list[int] = []
90
  mask: list[int] = []
 
154
  add_tokens(tool_call_end, loss_mask)
155
  elif isinstance(message["content"], list):
156
  # Multi-segment content from upstream tasks (GSM8K, SpellingBee, …).
157
+ # Mirror netis_amniota.tokenizer.RustBPETokenizer.render_conversation:
158
  # text → supervised, python → wrapped+supervised, python_output → unsupervised.
159
  for part in message["content"]:
160
  value_ids = tokenizer.encode(part["text"])
 
189
  Auto-dispatches to render_qwen_for_completion when the tokenizer is Qwen.
190
  """
191
  if getattr(tokenizer, "chat_kind", None) == "qwen":
192
+ from netis_amniota.agentic.tokenizer_qwen import render_qwen_for_completion
193
  return render_qwen_for_completion(tokenizer, conversation, max_tokens=max_tokens)
194
  convo = copy.deepcopy(conversation)
195
  if convo["messages"] and convo["messages"][-1]["role"] == "assistant":
{vendor/nanoai → netis_amniota}/agentic/tokenizer_qwen.py RENAMED
@@ -1,9 +1,9 @@
1
  """Qwen2.5 tokenizer wrapper exposing the same API as
2
- nanoai.tokenizer.RustBPETokenizer / nanoai.agentic.tokenizer.
3
 
4
  Goal: enable a d12-arch student to natively use Qwen2 BPE + Qwen chat
5
  template, removing the cross-tokenizer alignment loss that has dominated
6
- OPD failure in the nanoai-tokenizer + Qwen-teacher setup.
7
 
8
  Chat template (Qwen2.5 native):
9
  <|im_start|>system\n{system}<|im_end|>
@@ -124,8 +124,8 @@ class QwenTokenizer:
124
 
125
  # -------------------------------------------------------------------
126
  # Chat-template adapter API. The eval_loop calls these to stay agnostic
127
- # of which tokenizer/template combo is in use. The nanoai RustBPE +
128
- # nanoai-template path provides equivalent helpers in the agentic
129
  # tokenizer module.
130
  # -------------------------------------------------------------------
131
 
 
1
  """Qwen2.5 tokenizer wrapper exposing the same API as
2
+ netis_amniota.tokenizer.RustBPETokenizer / netis_amniota.agentic.tokenizer.
3
 
4
  Goal: enable a d12-arch student to natively use Qwen2 BPE + Qwen chat
5
  template, removing the cross-tokenizer alignment loss that has dominated
6
+ OPD failure in the netis_amniota-tokenizer + Qwen-teacher setup.
7
 
8
  Chat template (Qwen2.5 native):
9
  <|im_start|>system\n{system}<|im_end|>
 
124
 
125
  # -------------------------------------------------------------------
126
  # Chat-template adapter API. The eval_loop calls these to stay agnostic
127
+ # of which tokenizer/template combo is in use. The netis_amniota RustBPE +
128
+ # netis_amniota-template path provides equivalent helpers in the agentic
129
  # tokenizer module.
130
  # -------------------------------------------------------------------
131
 
{vendor/nanoai → netis_amniota}/checkpoint_manager.py RENAMED
@@ -8,10 +8,10 @@ import json
8
  import logging
9
  import torch
10
 
11
- from nanoai.common import get_base_dir
12
- from nanoai.gpt import GPT, GPTConfig
13
- from nanoai.tokenizer import get_tokenizer
14
- from nanoai.common import setup_default_logging
15
 
16
  # Set up logging
17
  setup_default_logging()
@@ -160,7 +160,7 @@ def find_last_step(checkpoint_dir):
160
  return last_step
161
 
162
  # -----------------------------------------------------------------------------
163
- # convenience functions that take into account nanoai's directory structure
164
 
165
  def load_model_from_dir(checkpoints_dir, device, phase, model_tag=None, step=None):
166
  if model_tag is None:
 
8
  import logging
9
  import torch
10
 
11
+ from netis_amniota.common import get_base_dir
12
+ from netis_amniota.gpt import GPT, GPTConfig
13
+ from netis_amniota.tokenizer import get_tokenizer
14
+ from netis_amniota.common import setup_default_logging
15
 
16
  # Set up logging
17
  setup_default_logging()
 
160
  return last_step
161
 
162
  # -----------------------------------------------------------------------------
163
+ # convenience functions that take into account netis_amniota's directory structure
164
 
165
  def load_model_from_dir(checkpoints_dir, device, phase, model_tag=None, step=None):
166
  if model_tag is None:
{vendor/nanoai → netis_amniota}/common.py RENAMED
@@ -1,5 +1,5 @@
1
  """
2
- Common utilities for nanoai.
3
  """
4
 
5
  import os
@@ -12,12 +12,12 @@ from filelock import FileLock
12
 
13
  # The dtype used for compute (matmuls, activations). Master weights stay fp32 for optimizer precision.
14
  # Linear layers cast their weights to this dtype in forward, replacing torch.amp.autocast.
15
- # Override with NANOAI_DTYPE env var: "bfloat16", "float16", "float32"
16
  _DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
17
  def _detect_compute_dtype():
18
- env = os.environ.get("NANOAI_DTYPE")
19
  if env is not None:
20
- return _DTYPE_MAP[env], f"set via NANOAI_DTYPE={env}"
21
  if torch.cuda.is_available():
22
  # bf16 requires SM 80+ (Ampere: A100, A10, etc.)
23
  # Older GPUs like V100 (SM 70) and T4 (SM 75) only have fp16 tensor cores
@@ -25,7 +25,7 @@ def _detect_compute_dtype():
25
  if capability >= (8, 0):
26
  return torch.bfloat16, f"auto-detected: CUDA SM {capability[0]}{capability[1]} (bf16 supported)"
27
  # fp16 training requires GradScaler (not yet implemented), so fall back to fp32.
28
- # Users can still force fp16 via NANOAI_DTYPE=float16 if they know what they're doing.
29
  return torch.float32, f"auto-detected: CUDA SM {capability[0]}{capability[1]} (pre-Ampere, bf16 not supported, using fp32)"
30
  return torch.float32, "auto-detected: no CUDA (CPU/MPS)"
31
  COMPUTE_DTYPE, COMPUTE_DTYPE_REASON = _detect_compute_dtype()
@@ -68,23 +68,23 @@ setup_default_logging()
68
  logger = logging.getLogger(__name__)
69
 
70
  def get_base_dir():
71
- # Co-locate nanoai intermediates with other cached data in ~/.cache (by default).
72
  # Back-compat: the project was formerly "nanochat". NANOCHAT_BASE_DIR is mirrored
73
- # onto NANOAI_BASE_DIR in nanoai/__init__.py, and if no nanoai cache dir exists yet
74
  # but a legacy ~/.cache/nanochat does, we use it so checkpoints trained under the
75
- # old name are not orphaned. Set NANOAI_BASE_DIR to override either way.
76
- env = os.environ.get("NANOAI_BASE_DIR")
77
  if env:
78
  base_dir = env
79
  else:
80
  cache_dir = os.path.join(os.path.expanduser("~"), ".cache")
81
- nanoai_dir = os.path.join(cache_dir, "nanoai")
82
  legacy_dir = os.path.join(cache_dir, "nanochat")
83
- if not os.path.isdir(nanoai_dir) and os.path.isdir(legacy_dir):
84
- logger.warning("nanoai: using legacy cache dir %s (set NANOAI_BASE_DIR to override)", legacy_dir)
85
  base_dir = legacy_dir
86
  else:
87
- base_dir = nanoai_dir
88
  os.makedirs(base_dir, exist_ok=True)
89
  return base_dir
90
 
@@ -192,9 +192,9 @@ def compute_init(device_type="cuda"): # cuda|cpu|mps
192
  # Reproducibility
193
  # Note that we set the global seeds here, but most of the code uses explicit rng objects.
194
  # The only place where global rng might be used is nn.Module initialization of the model weights.
195
- # Seed defaults to 42 (unchanged) but can be overridden via NANOAI_SEED for multi-seed
196
  # ablations (varies weight init only; data order is fixed by explicit rng objects).
197
- _seed = int(os.environ.get("NANOAI_SEED", "42"))
198
  torch.manual_seed(_seed)
199
  if device_type == "cuda":
200
  torch.cuda.manual_seed(_seed)
@@ -236,7 +236,7 @@ class DummyWandb:
236
 
237
  # hardcoded BF16 peak flops for various GPUs
238
  # inspired by torchtitan: https://github.com/pytorch/torchtitan/blob/main/torchtitan/tools/utils.py
239
- # and PR: https://github.com/karpathy/nanoai/pull/147
240
  def get_peak_flops(device_name: str, precision: str = "bf16") -> float:
241
  """Return dense tensor-core peak FLOPS for `device_name` at `precision`.
242
 
 
1
  """
2
+ Common utilities for netis_amniota.
3
  """
4
 
5
  import os
 
12
 
13
  # The dtype used for compute (matmuls, activations). Master weights stay fp32 for optimizer precision.
14
  # Linear layers cast their weights to this dtype in forward, replacing torch.amp.autocast.
15
+ # Override with NETIS_AMNIOTA_DTYPE env var: "bfloat16", "float16", "float32"
16
  _DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
17
  def _detect_compute_dtype():
18
+ env = os.environ.get("NETIS_AMNIOTA_DTYPE")
19
  if env is not None:
20
+ return _DTYPE_MAP[env], f"set via NETIS_AMNIOTA_DTYPE={env}"
21
  if torch.cuda.is_available():
22
  # bf16 requires SM 80+ (Ampere: A100, A10, etc.)
23
  # Older GPUs like V100 (SM 70) and T4 (SM 75) only have fp16 tensor cores
 
25
  if capability >= (8, 0):
26
  return torch.bfloat16, f"auto-detected: CUDA SM {capability[0]}{capability[1]} (bf16 supported)"
27
  # fp16 training requires GradScaler (not yet implemented), so fall back to fp32.
28
+ # Users can still force fp16 via NETIS_AMNIOTA_DTYPE=float16 if they know what they're doing.
29
  return torch.float32, f"auto-detected: CUDA SM {capability[0]}{capability[1]} (pre-Ampere, bf16 not supported, using fp32)"
30
  return torch.float32, "auto-detected: no CUDA (CPU/MPS)"
31
  COMPUTE_DTYPE, COMPUTE_DTYPE_REASON = _detect_compute_dtype()
 
68
  logger = logging.getLogger(__name__)
69
 
70
  def get_base_dir():
71
+ # Co-locate netis_amniota intermediates with other cached data in ~/.cache (by default).
72
  # Back-compat: the project was formerly "nanochat". NANOCHAT_BASE_DIR is mirrored
73
+ # onto NETIS_AMNIOTA_BASE_DIR in netis_amniota/__init__.py, and if no netis_amniota cache dir exists yet
74
  # but a legacy ~/.cache/nanochat does, we use it so checkpoints trained under the
75
+ # old name are not orphaned. Set NETIS_AMNIOTA_BASE_DIR to override either way.
76
+ env = os.environ.get("NETIS_AMNIOTA_BASE_DIR")
77
  if env:
78
  base_dir = env
79
  else:
80
  cache_dir = os.path.join(os.path.expanduser("~"), ".cache")
81
+ netis_amniota_dir = os.path.join(cache_dir, "netis_amniota")
82
  legacy_dir = os.path.join(cache_dir, "nanochat")
83
+ if not os.path.isdir(netis_amniota_dir) and os.path.isdir(legacy_dir):
84
+ logger.warning("netis_amniota: using legacy cache dir %s (set NETIS_AMNIOTA_BASE_DIR to override)", legacy_dir)
85
  base_dir = legacy_dir
86
  else:
87
+ base_dir = netis_amniota_dir
88
  os.makedirs(base_dir, exist_ok=True)
89
  return base_dir
90
 
 
192
  # Reproducibility
193
  # Note that we set the global seeds here, but most of the code uses explicit rng objects.
194
  # The only place where global rng might be used is nn.Module initialization of the model weights.
195
+ # Seed defaults to 42 (unchanged) but can be overridden via NETIS_AMNIOTA_SEED for multi-seed
196
  # ablations (varies weight init only; data order is fixed by explicit rng objects).
197
+ _seed = int(os.environ.get("NETIS_AMNIOTA_SEED", "42"))
198
  torch.manual_seed(_seed)
199
  if device_type == "cuda":
200
  torch.cuda.manual_seed(_seed)
 
236
 
237
  # hardcoded BF16 peak flops for various GPUs
238
  # inspired by torchtitan: https://github.com/pytorch/torchtitan/blob/main/torchtitan/tools/utils.py
239
+ # and PR: https://github.com/karpathy/netis_amniota/pull/147
240
  def get_peak_flops(device_name: str, precision: str = "bf16") -> float:
241
  """Return dense tensor-core peak FLOPS for `device_name` at `precision`.
242
 
{vendor/nanoai → netis_amniota}/flash_attention.py RENAMED
@@ -8,7 +8,7 @@ the best available backend:
8
  - SDPA fallback: all other GPUs, MPS, CPU
9
 
10
  Usage (drop-in replacement for FA3):
11
- from nanoai.flash_attention import flash_attn
12
 
13
  # Training (no KV cache)
14
  y = flash_attn.flash_attn_func(q, k, v, causal=True, window_size=window_size)
@@ -108,10 +108,10 @@ _fa3 = _load_flash_attention_3()
108
  HAS_FA3 = _fa3 is not None
109
 
110
  # Override for testing: set to 'fa4', 'fa3', 'sdpa', or None (auto)
111
- # Honor NANOAI_ATTN_BACKEND env var first so we can force-disable FA4 on
112
  # SM-mismatched builds (FA4 cute kernel asserts only sm_100/110f are valid).
113
  import os as _os
114
- _env_backend = _os.environ.get("NANOAI_ATTN_BACKEND", "").strip().lower()
115
  _override_impl = _env_backend if _env_backend in ("fa4", "fa3", "sdpa") else None
116
 
117
 
@@ -119,7 +119,7 @@ def _resolve_backend():
119
  """Decide which attention backend to use. Returns 'fa4', 'fa3', or 'sdpa'."""
120
  if _override_impl in ('fa4', 'fa3', 'sdpa'):
121
  return _override_impl
122
- from nanoai.common import COMPUTE_DTYPE
123
  # FA4 is preferred (supports both Hopper and Blackwell)
124
  if HAS_FA4 and COMPUTE_DTYPE == torch.bfloat16:
125
  return 'fa4'
 
8
  - SDPA fallback: all other GPUs, MPS, CPU
9
 
10
  Usage (drop-in replacement for FA3):
11
+ from netis_amniota.flash_attention import flash_attn
12
 
13
  # Training (no KV cache)
14
  y = flash_attn.flash_attn_func(q, k, v, causal=True, window_size=window_size)
 
108
  HAS_FA3 = _fa3 is not None
109
 
110
  # Override for testing: set to 'fa4', 'fa3', 'sdpa', or None (auto)
111
+ # Honor NETIS_AMNIOTA_ATTN_BACKEND env var first so we can force-disable FA4 on
112
  # SM-mismatched builds (FA4 cute kernel asserts only sm_100/110f are valid).
113
  import os as _os
114
+ _env_backend = _os.environ.get("NETIS_AMNIOTA_ATTN_BACKEND", "").strip().lower()
115
  _override_impl = _env_backend if _env_backend in ("fa4", "fa3", "sdpa") else None
116
 
117
 
 
119
  """Decide which attention backend to use. Returns 'fa4', 'fa3', or 'sdpa'."""
120
  if _override_impl in ('fa4', 'fa3', 'sdpa'):
121
  return _override_impl
122
+ from netis_amniota.common import COMPUTE_DTYPE
123
  # FA4 is preferred (supports both Hopper and Blackwell)
124
  if HAS_FA4 and COMPUTE_DTYPE == torch.bfloat16:
125
  return 'fa4'
{vendor/nanoai → netis_amniota}/gpt.py RENAMED
@@ -21,20 +21,20 @@ import torch.nn as nn
21
  import torch.utils.checkpoint as _ckpt
22
  import torch.nn.functional as F
23
 
24
- from nanoai.common import get_dist_info, print0, COMPUTE_DTYPE
25
- from nanoai.optim import MuonAdamW, DistMuonAdamW
26
  # GPTConfig is the single shared config (torch + MLX); re-exported here so
27
- # `from nanoai.gpt import GPTConfig` keeps working.
28
- from nanoai.model_config import GPTConfig, value_embed_table_map
29
 
30
  # Our custom Flash Attention module that automatically uses FA3 on Hopper+ and SDPA fallback elsewhere
31
- from nanoai.flash_attention import flash_attn
32
  # MSA (MiniMax-M3-style block-sparse attention) — self-contained subpackage
33
- from nanoai.msa import (
34
  msa_block_sparse_attn_func, msa_gather_decode, update_msa_pooled_cache,
35
  msa_fa4_block_sparse_attn, default_block_size,
36
  )
37
- from nanoai.sparse_attention import SparseIndexer, dsa_attention, hisa_attention
38
 
39
  def norm(x):
40
  return F.rms_norm(x, (x.size(-1),)) # note that this will run in bf16, seems ok
@@ -668,7 +668,7 @@ class GPT(nn.Module):
668
 
669
  def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
670
  if getattr(self, "_uses_nvfp4", False):
671
- from nanoai.nvfp4 import nvfp4_autocast
672
  with nvfp4_autocast():
673
  return self._forward_impl_nvfp4(idx, targets, kv_cache, loss_reduction)
674
  return self._forward_impl(idx, targets, kv_cache, loss_reduction)
@@ -831,7 +831,7 @@ class GPT(nn.Module):
831
  ids = torch.tensor([tokens], dtype=torch.long, device=device) # add batch dim
832
  for _ in range(max_tokens):
833
  if getattr(self, "_uses_nvfp4", False):
834
- from nanoai.nvfp4 import disable_nvfp4_autocast
835
  with disable_nvfp4_autocast():
836
  logits = self.forward(ids) # (B, T, vocab_size)
837
  else:
 
21
  import torch.utils.checkpoint as _ckpt
22
  import torch.nn.functional as F
23
 
24
+ from netis_amniota.common import get_dist_info, print0, COMPUTE_DTYPE
25
+ from netis_amniota.optim import MuonAdamW, DistMuonAdamW
26
  # GPTConfig is the single shared config (torch + MLX); re-exported here so
27
+ # `from netis_amniota.gpt import GPTConfig` keeps working.
28
+ from netis_amniota.model_config import GPTConfig, value_embed_table_map
29
 
30
  # Our custom Flash Attention module that automatically uses FA3 on Hopper+ and SDPA fallback elsewhere
31
+ from netis_amniota.flash_attention import flash_attn
32
  # MSA (MiniMax-M3-style block-sparse attention) — self-contained subpackage
33
+ from netis_amniota.msa import (
34
  msa_block_sparse_attn_func, msa_gather_decode, update_msa_pooled_cache,
35
  msa_fa4_block_sparse_attn, default_block_size,
36
  )
37
+ from netis_amniota.sparse_attention import SparseIndexer, dsa_attention, hisa_attention
38
 
39
  def norm(x):
40
  return F.rms_norm(x, (x.size(-1),)) # note that this will run in bf16, seems ok
 
668
 
669
  def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
670
  if getattr(self, "_uses_nvfp4", False):
671
+ from netis_amniota.nvfp4 import nvfp4_autocast
672
  with nvfp4_autocast():
673
  return self._forward_impl_nvfp4(idx, targets, kv_cache, loss_reduction)
674
  return self._forward_impl(idx, targets, kv_cache, loss_reduction)
 
831
  ids = torch.tensor([tokens], dtype=torch.long, device=device) # add batch dim
832
  for _ in range(max_tokens):
833
  if getattr(self, "_uses_nvfp4", False):
834
+ from netis_amniota.nvfp4 import disable_nvfp4_autocast
835
  with disable_nvfp4_autocast():
836
  logits = self.forward(ids) # (B, T, vocab_size)
837
  else:
{vendor/nanoai → netis_amniota}/model_config.py RENAMED
@@ -1,13 +1,13 @@
1
- """Shared model configuration for NanoAI.
2
 
3
- The single ``GPTConfig`` used by BOTH backends — the torch model (``nanoai.gpt``)
4
- and the MLX model (``nanoai.mlx.gpt``) — so checkpoints (``model_*.pt`` +
5
  ``meta_*.json`` carrying a top-level ``model_config``) interoperate across
6
  backends: a checkpoint trained with torch loads in MLX and vice versa.
7
 
8
  Pure stdlib (no torch, no mlx) so importing it is cheap and backend-agnostic;
9
- both ``nanoai.gpt`` and ``nanoai.mlx.gpt`` re-export ``GPTConfig`` from here, so
10
- existing ``from nanoai.gpt import GPTConfig`` / ``from nanoai.mlx.gpt import
11
  GPTConfig`` keep working.
12
  """
13
  from __future__ import annotations
@@ -24,8 +24,8 @@ def has_ve(layer_idx: int, n_layer: int) -> bool:
24
 
25
  def value_embed_table_map(n_layer: int, ve_n_unique: int):
26
  """Return ``(table_keys, layer_to_key)`` for value-embedding sharing — the ONE
27
- source of truth so the torch (``nanoai.gpt``), MLX (``nanoai.mlx.gpt``) and ANE
28
- (``nanoai.ane.model``) backends key ``value_embeds`` identically and thus load
29
  each other's checkpoints.
30
 
31
  - ``ve_n_unique == -1`` (legacy per-layer): one table per ``has_ve`` layer,
@@ -76,14 +76,14 @@ class GPTConfig:
76
  msa_index_mode: str = "pooled_k"
77
  # MSA train/prefill kernel on "M" layers: "sdpa" (any head_dim, correct,
78
  # overhead-bound) | "fa4" (FA4 native block-sparse, head_dim==64, fast).
79
- # Defaults to env NANOAI_MSA_KERNEL when left "sdpa" so experiments need not
80
  # touch base_train; the resolved value is saved in the checkpoint meta.
81
  msa_kernel: str = "sdpa"
82
  # DSA/HISA optional sparse-attention hyperparameters. These are only used by
83
  # torch backend layers whose window_pattern letter is D or H. The default
84
  # indexer is the training-free Q/K proxy used by the sparse-attention-indexing
85
  # experiments; "learned" instantiates explicit projection weights. Use
86
- # nanoai.sparse_attention.indexer_alignment_loss for auxiliary supervision
87
  # before hard top-k deployment.
88
  sparse_token_budget: int = 512
89
  sparse_block_size: int = 64
@@ -150,7 +150,7 @@ class GPTConfig:
150
 
151
  def __post_init__(self):
152
  if self.msa_kernel == "sdpa":
153
- self.msa_kernel = os.environ.get("NANOAI_MSA_KERNEL", "sdpa")
154
  assert self.n_experts >= 0, f"n_experts must be >= 0 (got {self.n_experts})"
155
  if self.n_experts > 0:
156
  assert self.n_experts_active > 0, "n_experts_active (top-k) must be > 0 when n_experts > 0"
 
1
+ """Shared model configuration for Netis Amniota.
2
 
3
+ The single ``GPTConfig`` used by BOTH backends — the torch model (``netis_amniota.gpt``)
4
+ and the MLX model (``netis_amniota.mlx.gpt``) — so checkpoints (``model_*.pt`` +
5
  ``meta_*.json`` carrying a top-level ``model_config``) interoperate across
6
  backends: a checkpoint trained with torch loads in MLX and vice versa.
7
 
8
  Pure stdlib (no torch, no mlx) so importing it is cheap and backend-agnostic;
9
+ both ``netis_amniota.gpt`` and ``netis_amniota.mlx.gpt`` re-export ``GPTConfig`` from here, so
10
+ existing ``from netis_amniota.gpt import GPTConfig`` / ``from netis_amniota.mlx.gpt import
11
  GPTConfig`` keep working.
12
  """
13
  from __future__ import annotations
 
24
 
25
  def value_embed_table_map(n_layer: int, ve_n_unique: int):
26
  """Return ``(table_keys, layer_to_key)`` for value-embedding sharing — the ONE
27
+ source of truth so the torch (``netis_amniota.gpt``), MLX (``netis_amniota.mlx.gpt``) and ANE
28
+ (``netis_amniota.ane.model``) backends key ``value_embeds`` identically and thus load
29
  each other's checkpoints.
30
 
31
  - ``ve_n_unique == -1`` (legacy per-layer): one table per ``has_ve`` layer,
 
76
  msa_index_mode: str = "pooled_k"
77
  # MSA train/prefill kernel on "M" layers: "sdpa" (any head_dim, correct,
78
  # overhead-bound) | "fa4" (FA4 native block-sparse, head_dim==64, fast).
79
+ # Defaults to env NETIS_AMNIOTA_MSA_KERNEL when left "sdpa" so experiments need not
80
  # touch base_train; the resolved value is saved in the checkpoint meta.
81
  msa_kernel: str = "sdpa"
82
  # DSA/HISA optional sparse-attention hyperparameters. These are only used by
83
  # torch backend layers whose window_pattern letter is D or H. The default
84
  # indexer is the training-free Q/K proxy used by the sparse-attention-indexing
85
  # experiments; "learned" instantiates explicit projection weights. Use
86
+ # netis_amniota.sparse_attention.indexer_alignment_loss for auxiliary supervision
87
  # before hard top-k deployment.
88
  sparse_token_budget: int = 512
89
  sparse_block_size: int = 64
 
150
 
151
  def __post_init__(self):
152
  if self.msa_kernel == "sdpa":
153
+ self.msa_kernel = os.environ.get("NETIS_AMNIOTA_MSA_KERNEL", "sdpa")
154
  assert self.n_experts >= 0, f"n_experts must be >= 0 (got {self.n_experts})"
155
  if self.n_experts > 0:
156
  assert self.n_experts_active > 0, "n_experts_active (top-k) must be > 0 when n_experts > 0"
{vendor/nanoai → netis_amniota}/msa/__init__.py RENAMED
@@ -1,11 +1,11 @@
1
  """
2
- MSA: MiniMax-M3-style block-sparse attention for nanoai.
3
 
4
  Self-contained subpackage holding everything MSA-specific:
5
  - attention.py : the block-sparse attention math (mask path + gather decode)
6
  - runtime.py : model-side helpers (post-hoc enable, rotary extension)
7
 
8
- The only in-place hooks in the core model live in nanoai/gpt.py:
9
  - GPTConfig.msa_* fields + the "M" character in window_pattern
10
  - CausalSelfAttention.forward dispatch to msa_block_sparse_attn_func /
11
  msa_gather_decode
@@ -14,14 +14,14 @@ The only in-place hooks in the core model live in nanoai/gpt.py:
14
  See docs / the design plan for the staged Stage-A (no-retrain probe) →
15
  Stage-B (native training) rollout.
16
  """
17
- from nanoai.msa.attention import (
18
  msa_block_sparse_attn_func,
19
  msa_gather_decode,
20
  _msa_block_selection,
21
  update_msa_pooled_cache,
22
  )
23
- from nanoai.msa.runtime import apply_msa, num_msa_layers, extend_rotary
24
- from nanoai.msa.fa4 import (
25
  msa_fa4_block_sparse_attn,
26
  build_msa_block_sparse,
27
  default_block_size,
 
1
  """
2
+ MSA: MiniMax-M3-style block-sparse attention for netis_amniota.
3
 
4
  Self-contained subpackage holding everything MSA-specific:
5
  - attention.py : the block-sparse attention math (mask path + gather decode)
6
  - runtime.py : model-side helpers (post-hoc enable, rotary extension)
7
 
8
+ The only in-place hooks in the core model live in netis_amniota/gpt.py:
9
  - GPTConfig.msa_* fields + the "M" character in window_pattern
10
  - CausalSelfAttention.forward dispatch to msa_block_sparse_attn_func /
11
  msa_gather_decode
 
14
  See docs / the design plan for the staged Stage-A (no-retrain probe) →
15
  Stage-B (native training) rollout.
16
  """
17
+ from netis_amniota.msa.attention import (
18
  msa_block_sparse_attn_func,
19
  msa_gather_decode,
20
  _msa_block_selection,
21
  update_msa_pooled_cache,
22
  )
23
+ from netis_amniota.msa.runtime import apply_msa, num_msa_layers, extend_rotary
24
+ from netis_amniota.msa.fa4 import (
25
  msa_fa4_block_sparse_attn,
26
  build_msa_block_sparse,
27
  default_block_size,
{vendor/nanoai → netis_amniota}/msa/attention.py RENAMED
File without changes
{vendor/nanoai → netis_amniota}/msa/fa4.py RENAMED
@@ -46,11 +46,11 @@ except Exception:
46
  if _FA4_AVAILABLE:
47
  # The SM100 block-sparse kernel asserts `sm_100 <= arch <= sm_110f`, but an
48
  # upstream Arch-enum typo aliases sm_110f to sm_101f, so B300 (sm_103) trips a
49
- # kernel it supports. nanoai.flash_attention ships the rebind fix; the model
50
  # path imports it via gpt.py, but apply it here too so the standalone MSA-FA4
51
  # path (and its tests) works on B300 regardless of import order.
52
  try:
53
- from nanoai.flash_attention import _patch_fa4_arch_enum as _patch_fa4_arch
54
  _patch_fa4_arch()
55
  except Exception:
56
  pass
 
46
  if _FA4_AVAILABLE:
47
  # The SM100 block-sparse kernel asserts `sm_100 <= arch <= sm_110f`, but an
48
  # upstream Arch-enum typo aliases sm_110f to sm_101f, so B300 (sm_103) trips a
49
+ # kernel it supports. netis_amniota.flash_attention ships the rebind fix; the model
50
  # path imports it via gpt.py, but apply it here too so the standalone MSA-FA4
51
  # path (and its tests) works on B300 regardless of import order.
52
  try:
53
+ from netis_amniota.flash_attention import _patch_fa4_arch_enum as _patch_fa4_arch
54
  _patch_fa4_arch()
55
  except Exception:
56
  pass
{vendor/nanoai → netis_amniota}/msa/runtime.py RENAMED
@@ -1,6 +1,6 @@
1
  """
2
  MSA runtime helpers (model-side glue, no attention math — that lives in
3
- nanoai.msa.attention).
4
 
5
  - apply_msa: POST-HOC enable MSA on a (dense-trained) model, used by the
6
  no-retrain Stage-A probe. It flips the chosen layers to "msa" and sets the
@@ -11,7 +11,7 @@ nanoai.msa.attention).
11
  otherwise trips the rotary-cache assert in GPT._forward_impl).
12
  - num_msa_layers: count "msa" layers on a model.
13
  """
14
- from nanoai.common import COMPUTE_DTYPE
15
 
16
 
17
  def apply_msa(model, *, msa_layers="auto", block_size=64, top_k_blocks=16,
 
1
  """
2
  MSA runtime helpers (model-side glue, no attention math — that lives in
3
+ netis_amniota.msa.attention).
4
 
5
  - apply_msa: POST-HOC enable MSA on a (dense-trained) model, used by the
6
  no-retrain Stage-A probe. It flips the chosen layers to "msa" and sets the
 
11
  otherwise trips the rotary-cache assert in GPT._forward_impl).
12
  - num_msa_layers: count "msa" layers on a model.
13
  """
14
+ from netis_amniota.common import COMPUTE_DTYPE
15
 
16
 
17
  def apply_msa(model, *, msa_layers="auto", block_size=64, top_k_blocks=16,
{vendor/nanoai → netis_amniota}/nvfp4.py RENAMED
@@ -1,6 +1,6 @@
1
- """NVFP4 training for nanoai — using NVIDIA TransformerEngine.
2
 
3
- Drop-in replacement for nanoai's Float8Linear. Uses TransformerEngine's
4
  te.Linear to quantize weights and activations to 4-bit floating point
5
  (E2M1 format with 16-element micro-block scaling).
6
 
@@ -20,7 +20,7 @@ as te_linear.weight/bias.
20
 
21
  This ensures:
22
  1. model.parameters() yields only standard-shaped weight/bias tensors
23
- (same as nn.Linear), so nanoai's optimizer param group classification
24
  (Muon vs AdamW by parameter name/shape) works correctly.
25
  2. TE's forward pass uses the same parameter objects (no copy overhead).
26
  3. State dict keys are standard 'weight'/'bias' — cross-compatible with
@@ -250,7 +250,7 @@ def convert_to_float4_training(module, *, config=None, module_filter_fn=None):
250
  config: Float4LinearConfig (accepted for API compat, currently unused).
251
  module_filter_fn: Optional callable(module, fqn) -> bool.
252
  """
253
- from nanoai.fp8 import Float8Linear # avoid circular import
254
 
255
  if config is None:
256
  config = Float4LinearConfig()
@@ -361,14 +361,14 @@ def probe_nvfp4_forward(
361
  # ---------------------------------------------------------------------------
362
  # DeepSeek-V4 ships a production FP4 recipe where ONLY the large, loss-insensitive
363
  # MLP/expert weights are FP4; attention projections, the LM head, and embeddings
364
- # stay at higher precision (FP8/BF16). nanoai's stock `quant_module_filter`
365
  # selects purely by shape (dims %16, min>=128) and ignores `fqn`, so attention
366
  # q/k/v/o and lm_head were ALSO FP4'd — a common cause of low-bit accuracy loss.
367
  #
368
  # `make_quant_filter` keeps the shape guards but additionally excludes sensitive
369
  # modules by fully-qualified name. It only ever REDUCES the quantized set vs the
370
  # shape-only filter, so it is a strictly-safer drop-in for A/B experiments.
371
- # Module names verified against nanoai/gpt.py: attention linears live under
372
  # `.attn.` (c_q/c_k/c_v/c_proj/ve_gate); MLP under `.mlp.` (c_fc/c_proj); head is
373
  # `lm_head`; embeddings are `wte` / `value_embeds`.
374
 
 
1
+ """NVFP4 training for netis_amniota — using NVIDIA TransformerEngine.
2
 
3
+ Drop-in replacement for netis_amniota's Float8Linear. Uses TransformerEngine's
4
  te.Linear to quantize weights and activations to 4-bit floating point
5
  (E2M1 format with 16-element micro-block scaling).
6
 
 
20
 
21
  This ensures:
22
  1. model.parameters() yields only standard-shaped weight/bias tensors
23
+ (same as nn.Linear), so netis_amniota's optimizer param group classification
24
  (Muon vs AdamW by parameter name/shape) works correctly.
25
  2. TE's forward pass uses the same parameter objects (no copy overhead).
26
  3. State dict keys are standard 'weight'/'bias' — cross-compatible with
 
250
  config: Float4LinearConfig (accepted for API compat, currently unused).
251
  module_filter_fn: Optional callable(module, fqn) -> bool.
252
  """
253
+ from netis_amniota.fp8 import Float8Linear # avoid circular import
254
 
255
  if config is None:
256
  config = Float4LinearConfig()
 
361
  # ---------------------------------------------------------------------------
362
  # DeepSeek-V4 ships a production FP4 recipe where ONLY the large, loss-insensitive
363
  # MLP/expert weights are FP4; attention projections, the LM head, and embeddings
364
+ # stay at higher precision (FP8/BF16). netis_amniota's stock `quant_module_filter`
365
  # selects purely by shape (dims %16, min>=128) and ignores `fqn`, so attention
366
  # q/k/v/o and lm_head were ALSO FP4'd — a common cause of low-bit accuracy loss.
367
  #
368
  # `make_quant_filter` keeps the shape guards but additionally excludes sensitive
369
  # modules by fully-qualified name. It only ever REDUCES the quantized set vs the
370
  # shape-only filter, so it is a strictly-safer drop-in for A/B experiments.
371
+ # Module names verified against netis_amniota/gpt.py: attention linears live under
372
  # `.attn.` (c_q/c_k/c_v/c_proj/ve_gate); MLP under `.mlp.` (c_fc/c_proj); head is
373
  # `lm_head`; embeddings are `wte` / `value_embeds`.
374
 
{vendor/nanoai → netis_amniota}/optim.py RENAMED
@@ -71,7 +71,7 @@ NorMuon variance reduction: per-neuron/column adaptive learning rate that normal
71
  update scales after orthogonalization (Muon's output has non-uniform scales across neurons).
72
  https://arxiv.org/pdf/2510.05491
73
 
74
- Some of the changes in nanoai implementation:
75
  - Uses a simpler, more general approach to parameter grouping and stacking
76
  - Uses a single fused kernel for the momentum -> polar_express -> variance_reduction -> update step
77
  - Makes no assumptions about model architecture (e.g. that attention weights are fused into QKVO format)
 
71
  update scales after orthogonalization (Muon's output has non-uniform scales across neurons).
72
  https://arxiv.org/pdf/2510.05491
73
 
74
+ Some of the changes in netis_amniota implementation:
75
  - Uses a simpler, more general approach to parameter grouping and stacking
76
  - Uses a single fused kernel for the momentum -> polar_express -> variance_reduction -> update step
77
  - Makes no assumptions about model architecture (e.g. that attention weights are fused into QKVO format)
{vendor/nanoai → netis_amniota}/sparse_attention.py RENAMED
@@ -1,4 +1,4 @@
1
- """DSA/HISA sparse-attention primitives for NanoAI.
2
 
3
  This module is deliberately conservative: it provides correct PyTorch reference
4
  paths and an optional indexer scaffold, while leaving faster experimental
 
1
+ """DSA/HISA sparse-attention primitives for Netis Amniota.
2
 
3
  This module is deliberately conservative: it provides correct PyTorch reference
4
  paths and an optional indexer scaffold, while leaving faster experimental
{vendor/nanoai → netis_amniota}/tokenizer.py RENAMED
@@ -106,7 +106,7 @@ class HuggingFaceTokenizer:
106
  def _encode_one(self, text, prepend=None, append=None, num_threads=None):
107
  # encode a single string
108
  # prepend/append can be either a string of a special token or a token id directly.
109
- # num_threads is ignored (only used by the nanoai Tokenizer for parallel encoding)
110
  assert isinstance(text, str)
111
  ids = []
112
  if prepend is not None:
@@ -385,23 +385,23 @@ class RustBPETokenizer:
385
  return ids
386
 
387
  # -----------------------------------------------------------------------------
388
- # nanoai-specific convenience functions
389
 
390
  def get_tokenizer():
391
- from nanoai.common import get_base_dir
392
  base_dir = get_base_dir()
393
  tokenizer_dir = os.path.join(base_dir, "tokenizer")
394
- # Qwen-style tokenizer dir (HF-format) has tokenizer_config.json; nanoai
395
  # rustbpe dir has tokenizer.pkl. Detect and dispatch.
396
  if os.path.exists(os.path.join(tokenizer_dir, "tokenizer_config.json")) \
397
  and not os.path.exists(os.path.join(tokenizer_dir, "tokenizer.pkl")):
398
- from nanoai.agentic.tokenizer_qwen import QwenTokenizer
399
  return QwenTokenizer.from_directory(tokenizer_dir)
400
  return RustBPETokenizer.from_directory(tokenizer_dir)
401
 
402
  def get_token_bytes(device="cpu"):
403
  import torch
404
- from nanoai.common import get_base_dir
405
  base_dir = get_base_dir()
406
  tokenizer_dir = os.path.join(base_dir, "tokenizer")
407
  token_bytes_path = os.path.join(tokenizer_dir, "token_bytes.pt")
 
106
  def _encode_one(self, text, prepend=None, append=None, num_threads=None):
107
  # encode a single string
108
  # prepend/append can be either a string of a special token or a token id directly.
109
+ # num_threads is ignored (only used by the netis_amniota Tokenizer for parallel encoding)
110
  assert isinstance(text, str)
111
  ids = []
112
  if prepend is not None:
 
385
  return ids
386
 
387
  # -----------------------------------------------------------------------------
388
+ # netis_amniota-specific convenience functions
389
 
390
  def get_tokenizer():
391
+ from netis_amniota.common import get_base_dir
392
  base_dir = get_base_dir()
393
  tokenizer_dir = os.path.join(base_dir, "tokenizer")
394
+ # Qwen-style tokenizer dir (HF-format) has tokenizer_config.json; netis_amniota
395
  # rustbpe dir has tokenizer.pkl. Detect and dispatch.
396
  if os.path.exists(os.path.join(tokenizer_dir, "tokenizer_config.json")) \
397
  and not os.path.exists(os.path.join(tokenizer_dir, "tokenizer.pkl")):
398
+ from netis_amniota.agentic.tokenizer_qwen import QwenTokenizer
399
  return QwenTokenizer.from_directory(tokenizer_dir)
400
  return RustBPETokenizer.from_directory(tokenizer_dir)
401
 
402
  def get_token_bytes(device="cpu"):
403
  import torch
404
+ from netis_amniota.common import get_base_dir
405
  base_dir = get_base_dir()
406
  tokenizer_dir = os.path.join(base_dir, "tokenizer")
407
  token_bytes_path = os.path.join(tokenizer_dir, "token_bytes.pt")
tokenization_ravenguard.py CHANGED
@@ -1,6 +1,6 @@
1
  """RavenGuard tokenizer (trust_remote_code).
2
 
3
- Thin ``PreTrainedTokenizer`` wrapper around the nanoai rustbpe/tiktoken encoding
4
  pickled in ``tokenizer.pkl``. ``encode``/``decode`` delegate straight to the
5
  tiktoken ``Encoding`` so text round-trips byte-for-byte, matching how the guard
6
  was trained. Vocab size is the tiktoken ``n_vocab`` (65536).
 
1
  """RavenGuard tokenizer (trust_remote_code).
2
 
3
+ Thin ``PreTrainedTokenizer`` wrapper around the Netis Amniota rustbpe/tiktoken encoding
4
  pickled in ``tokenizer.pkl``. ``encode``/``decode`` delegate straight to the
5
  tiktoken ``Encoding`` so text round-trips byte-for-byte, matching how the guard
6
  was trained. Vocab size is the tiktoken ``n_vocab`` (65536).
vendor/nanoai/__init__.py DELETED
@@ -1,20 +0,0 @@
1
- """NanoAI — SOTA micro models, end to end.
2
-
3
- Formerly ``nanochat``. For back-compat we mirror the legacy ``NANOCHAT_*``
4
- environment variables onto their ``NANOAI_*`` equivalents (and vice versa) at
5
- package import time, so existing launch scripts / remote shells that still set
6
- ``NANOCHAT_*`` keep working unchanged. This runs before any submodule reads an
7
- env var (importing ``nanoai.x`` executes this ``__init__`` first).
8
- """
9
- import os as _os
10
-
11
- _ENV_SUFFIXES = (
12
- "BASE_DIR", "DTYPE", "ATTN_BACKEND", "MSA_KERNEL", "PACK_VERIFY",
13
- "DIR", "ROOT", "GROUNDED_JSONL", "TRAINING_PCAPS",
14
- )
15
- for _suf in _ENV_SUFFIXES:
16
- _old, _new = "NANOCHAT_" + _suf, "NANOAI_" + _suf
17
- if _new not in _os.environ and _old in _os.environ:
18
- _os.environ[_new] = _os.environ[_old]
19
- elif _old not in _os.environ and _new in _os.environ:
20
- _os.environ[_old] = _os.environ[_new]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/README.md DELETED
@@ -1,68 +0,0 @@
1
- # nanoai/agentic — shared agentic infrastructure
2
-
3
- The agentic post-training stack: pretrain on a web+code mix, SFT on tool-call
4
- conversations, DPO over agentic preferences, a family of RL / RLVR trainers, and
5
- offline tool-call + diagnostic evals. It stacks with the `--nvfp4` training stack
6
- and runs alongside the base `chat_sft` / `chat_rl` flow without modifying it.
7
-
8
- For day-to-day operation and the full pitfalls log see [CLAUDE.md](../../CLAUDE.md);
9
- research goals and playbook are in `goal.md` / `goal_next.md`.
10
-
11
- ## Infra vs experiment boundary (Stage 4 outcome)
12
-
13
- This package is **shared agentic infrastructure** — the reusable library that the
14
- experiment groups under `experiments/<group>/scripts/` all build on. The Stage-4
15
- audit confirmed every module here is used **across multiple experiment groups or
16
- by core infra**, so none is scattered into a single group's `lib/` (that would
17
- create cross-group import edges). Evidence:
18
-
19
- | module | used by | verdict |
20
- |---|---|---|
21
- | `diag/` (graders, schemas, scenarios) | ksbr_pcap **+ vrmining** | shared |
22
- | `hard_tasks/` | ksbr_pcap + agentic_rl + mcts_inference | shared |
23
- | `mcts_inference` / `mcts_exec` / `mcts_hybrid` | mcts_inference + agentic_rl | shared |
24
- | `prefix_merging` | agentic_rl + polar repro | shared |
25
- | `opd_loss` | agentic_rl (+ `config.seg_weights` mirrors it) | shared loss lib |
26
- | `dapt_probes` | **`scripts/base_train`** + ksbr | infra-referenced |
27
- | `cross_tokenizer_kd` | **`teacher`** (this pkg) | infra-referenced |
28
- | `reward_*`, `dense_verifier`, `grpo_multi_rollout*`, `tokenizer*`, `teacher`, `value_head`, `turn_*`, `data/` | many | shared |
29
-
30
- **The experiment/infra split is therefore: entrypoint scripts/runs/docs live in
31
- `experiments/<group>/`; this directory is the shared library they import.** Infra
32
- here imports **no** experiment module (verified). The one infra-referenced
33
- entrypoint kept in `scripts/` is `eval_llm_judge_rc_hit.py`
34
- (`reward_capbench` lazily imports `_judge_one` from it).
35
-
36
- ## Tool-call format (mirrors nanocode)
37
-
38
- ```
39
- <|tool_call_start|>Bash<|tool_arg|>command<|tool_val|>ls -la<|tool_call_end|>
40
- ```
41
-
42
- 6 agentic special tokens extend the 9 base tokens (15 total):
43
- `<|tool_call_start|>`, `<|tool_arg|>`, `<|tool_val|>`, `<|tool_call_end|>`,
44
- `<|tool_result_start|>`, `<|tool_result_end|>`. See `tokenizer.py` /
45
- `scripts/agentic_tok_train.py`.
46
-
47
- ## Modules
48
-
49
- - **Data**: `code_dataloader.py` (web+code Bernoulli mixer), `synth_dataloader.py`,
50
- `data/` (json/hf datasets, mixtures, `data/common.py` SYSTEM_PROMPT, pretrain/pcap/ebook/synth iterators).
51
- - **Tokenizers**: `tokenizer.py` (nanoai), `tokenizer_qwen.py` (Qwen teacher), `cross_tokenizer_kd.py`.
52
- - **Rewards**: `reward_mlr.py` (multi-level per-turn; weights from `nanoai.config.reward()`),
53
- `reward_mt.py`, `reward.py`, `reward_capbench.py`, `dense_verifier.py`, `opd_loss.py` (on-policy distillation).
54
- - **RL plumbing**: `grpo_multi_rollout{,_batched}.py`, `prefix_merging.py`, `value_head.py`,
55
- `turn_critic.py`, `turn_weight.py`, `mc_turn_value.py`, `ocaw.py`.
56
- - **Inference / search**: `mcts_inference.py`, `mcts_exec.py`, `mcts_hybrid.py`.
57
- - **Eval**: `eval_loop.py`, `eval_tasks.py`, `eval_pcap_tasks.py`, `eval_sandbox.py`
58
- (the sandbox path hard-depends on `firejail` — verify per machine).
59
- - **diag/**: graders (v1/v2/v3), schemas, chain_adapters, scenario generation.
60
- - **hard_tasks/**: task families (K-fault / S-wrapper / S-yaml / S-toolcall / SWE) for curriculum + grading.
61
-
62
- ## Trainers (in `scripts/`)
63
-
64
- `agentic_sft.py`, `agentic_dpo.py`, and the RL family
65
- (`agentic_{gtpo,dapo,real,tree_grpo,grpo_v2,gft_full,…}.py`). They share
66
- `pack_for_grad` / `setup_optimizer` patterns — see CLAUDE.md's pitfalls
67
- (per-token advantage off-by-one, single-`--lr` clobber, length-match) before
68
- editing any of them.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/__init__.py DELETED
@@ -1,7 +0,0 @@
1
- """Agentic code workflow extensions on top of nanoai.
2
-
3
- Adds: code-data mixing in pretrain, tool-call tokenizer, agentic SFT/DPO,
4
- and a tool-call validation eval. Designed to stack with the existing NVFP4
5
- training stack — every script accepts --nvfp4 and reuses Float4Linear from
6
- nanoai.nvfp4.
7
- """
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/code_dataloader.py DELETED
@@ -1,407 +0,0 @@
1
- """Code-mixing pretrain dataloader.
2
-
3
- Wraps nanoai's BOS-aligned best-fit packing but draws documents from up to
4
- three parquet sources with a categorical (Dirichlet-multinomial) mixture:
5
-
6
- - web : nanoai.dataset (climbmix or fineweb-edu, whatever's on disk)
7
- - code : nanoai.agentic.data.pretrain_data → smohammadi/the-stack-v2-python-shuffle
8
- - pcap : nanoai.agentic.data.pcap_data (network/protocol domain corpus)
9
-
10
- `code_ratio + pcap_ratio` must be ≤ 1; the remainder is `web_ratio`. Two-way
11
- defaults (no pcap) preserve the original 2-stream behaviour exactly.
12
-
13
- Resume state is intentionally simplified to a single epoch counter — the
14
- fine-grained (pq_idx, rg_idx) state of the upstream loader doesn't extend
15
- naturally to interleaved streams. For multi-day runs we'd want real
16
- multi-stream resume, but for d3/d6/d12 speedruns a fresh start at each step is
17
- fine.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import json
23
- import os
24
- import random
25
- from pathlib import Path
26
-
27
- import pyarrow.parquet as pq
28
- import torch
29
-
30
- from nanoai.common import get_dist_info, print0
31
- from nanoai.dataset import list_parquet_files as list_web_parquet_files
32
- from nanoai.agentic.data.pretrain_data import DATA_DIR as _CODE_BASE_DATA_DIR
33
- from nanoai.agentic.data.pcap_data import (
34
- DATASET_NAME as _PCAP_DATASET_NAME,
35
- list_pcap_parquet_files,
36
- )
37
- from nanoai.agentic.data.synth_data import (
38
- list_synth_parquet_files,
39
- synth_doc_batches,
40
- )
41
- from nanoai.agentic.data.vrcpt_data import (
42
- DATASET_NAME as _VRCPT_DATASET_NAME,
43
- list_vrcpt_parquet_files,
44
- )
45
- from nanoai.agentic.data.ebook_data import (
46
- DATASET_NAME as _EBOOK_DATASET_NAME,
47
- list_ebook_parquet_files,
48
- )
49
-
50
-
51
- _CODE_DATASET_NAME = "the-stack-v2-dedup"
52
- # Default location for KSR Phase-2 synth SWE triples; override via $SYNTH_SWE_DIR.
53
- _SYNTH_SWE_DIR_DEFAULT = Path.home() / "Miros" / "mlros" / "registry_data" / "synth_swe" / "v1"
54
-
55
-
56
- def _list_code_parquet_files() -> list[Path]:
57
- code_dir = _CODE_BASE_DATA_DIR / _CODE_DATASET_NAME
58
- if not code_dir.exists():
59
- return []
60
- return sorted([code_dir / f for f in code_dir.iterdir() if f.suffix == ".parquet"])
61
-
62
-
63
- def _list_synth_swe_jsonl_files(synth_swe_dir: Path | None = None) -> list[Path]:
64
- """List cpt.jsonl files produced by scripts/synth_swe_triples.py
65
- (one per SWE subtype). Empty if Phase-2 synth has not run yet."""
66
- d = synth_swe_dir or Path(os.environ.get("SYNTH_SWE_DIR", _SYNTH_SWE_DIR_DEFAULT))
67
- if not d.exists():
68
- return []
69
- return sorted(d.glob("*/cpt.jsonl"))
70
-
71
-
72
- def _jsonl_doc_batches_from(paths, ddp_rank, ddp_world_size, tokenizer_batch_size):
73
- """Infinite per-rank iterator over JSONL shards with a `text` field.
74
-
75
- Each line is parsed as JSON and the `text` field extracted. Lines are
76
- striped across ranks by index so each rank sees a disjoint subset
77
- (no parquet row-group concept here)."""
78
- while True:
79
- for filepath in paths:
80
- with open(filepath, "r", encoding="utf-8") as f:
81
- batch: list[str] = []
82
- for i, line in enumerate(f):
83
- if i % ddp_world_size != ddp_rank:
84
- continue
85
- line = line.strip()
86
- if not line:
87
- continue
88
- try:
89
- obj = json.loads(line)
90
- except json.JSONDecodeError:
91
- continue
92
- txt = obj.get("text")
93
- if isinstance(txt, str) and txt:
94
- batch.append(txt)
95
- if len(batch) >= tokenizer_batch_size:
96
- yield batch
97
- batch = []
98
- if batch:
99
- yield batch
100
-
101
-
102
- def _doc_batches_from(paths, ddp_rank, ddp_world_size, tokenizer_batch_size):
103
- """Infinite per-rank iterator: yields list[str] batches."""
104
- while True:
105
- for filepath in paths:
106
- pf = pq.ParquetFile(filepath)
107
- for rg_idx in range(ddp_rank, pf.num_row_groups, ddp_world_size):
108
- rg = pf.read_row_group(rg_idx)
109
- batch = rg.column("text").to_pylist()
110
- for i in range(0, len(batch), tokenizer_batch_size):
111
- yield batch[i : i + tokenizer_batch_size]
112
-
113
-
114
- def _split_paths(paths, split):
115
- if not paths:
116
- return []
117
- return paths[:-1] if split == "train" else paths[-1:]
118
-
119
-
120
- def _mixed_document_batches(
121
- split: str,
122
- code_ratio: float,
123
- pcap_ratio: float,
124
- tokenizer_batch_size: int,
125
- seed: int = 0,
126
- synth_swe_ratio: float = 0.0,
127
- synth_swe_dir: Path | None = None,
128
- synth_pleias_ratio: float = 0.0,
129
- vrcpt_ratio: float = 0.0,
130
- ebook_ratio: float = 0.0,
131
- ):
132
- assert 0.0 <= code_ratio <= 1.0, f"bad code_ratio={code_ratio}"
133
- assert 0.0 <= pcap_ratio <= 1.0, f"bad pcap_ratio={pcap_ratio}"
134
- assert 0.0 <= synth_swe_ratio <= 1.0, f"bad synth_swe_ratio={synth_swe_ratio}"
135
- assert 0.0 <= synth_pleias_ratio <= 1.0, f"bad synth_pleias_ratio={synth_pleias_ratio}"
136
- assert 0.0 <= vrcpt_ratio <= 1.0, f"bad vrcpt_ratio={vrcpt_ratio}"
137
- assert 0.0 <= ebook_ratio <= 1.0, f"bad ebook_ratio={ebook_ratio}"
138
- assert code_ratio + pcap_ratio + synth_swe_ratio + synth_pleias_ratio + vrcpt_ratio + ebook_ratio <= 1.0 + 1e-9, (
139
- f"code+pcap+synth_swe+synth_pleias+vrcpt+ebook = "
140
- f"{code_ratio+pcap_ratio+synth_swe_ratio+synth_pleias_ratio+vrcpt_ratio+ebook_ratio} > 1"
141
- )
142
-
143
- ddp, ddp_rank, _local, ddp_world_size = get_dist_info()
144
- warn = (ddp_rank == 0) and (split == "train")
145
-
146
- web_paths = list_web_parquet_files(warn_on_legacy=warn)
147
- assert web_paths, "No web (climbmix/fineweb-edu) parquet shards found; run nanoai.dataset first."
148
- web_paths = [Path(p) for p in _split_paths(web_paths, split)]
149
-
150
- code_paths = _split_paths(_list_code_parquet_files(), split)
151
- if code_ratio > 0 and not code_paths:
152
- raise FileNotFoundError(
153
- "code_ratio > 0 but no the-stack-v2-dedup parquet found. Run:\n"
154
- " python -m nanoai.agentic.data.pretrain_data -d the-stack-v2-dedup -n 8"
155
- )
156
-
157
- pcap_paths = _split_paths(list_pcap_parquet_files(), split)
158
- if pcap_ratio > 0 and not pcap_paths:
159
- raise FileNotFoundError(
160
- f"pcap_ratio > 0 but no {_PCAP_DATASET_NAME} parquet found. Run:\n"
161
- " python -m nanoai.agentic.data.pcap_data prepare --src /path/to/pretrain.jsonl"
162
- )
163
-
164
- synth_swe_paths = _list_synth_swe_jsonl_files(synth_swe_dir)
165
- synth_swe_paths = _split_paths(synth_swe_paths, split)
166
- if synth_swe_ratio > 0 and not synth_swe_paths:
167
- raise FileNotFoundError(
168
- "synth_swe_ratio > 0 but no synth_swe/v1/<subtype>/cpt.jsonl found. Run:\n"
169
- " python -m scripts.synth_swe_triples --out ~/Miros/mlros/registry_data/synth_swe/v1 --n-per-subtype 500"
170
- )
171
-
172
- synth_pleias_paths = _split_paths(list_synth_parquet_files(), split)
173
- if synth_pleias_ratio > 0 and not synth_pleias_paths:
174
- raise FileNotFoundError(
175
- "synth_pleias_ratio > 0 but no synth-pleias parquet found. Expected at "
176
- "$NANOAI_BASE_DIR/code_pretrain_data/synth-pleias/ (symlink to PleIAs/SYNTH)."
177
- )
178
-
179
- vrcpt_paths = _split_paths(list_vrcpt_parquet_files(), split)
180
- if vrcpt_ratio > 0 and not vrcpt_paths:
181
- raise FileNotFoundError(
182
- f"vrcpt_ratio > 0 but no {_VRCPT_DATASET_NAME} parquet found. Run:\n"
183
- " python -m nanoai.agentic.data.vrcpt_data prepare --src experiments/vrmining/datasets/cpt_corpus.jsonl"
184
- )
185
-
186
- ebook_paths = _split_paths([Path(p) for p in list_ebook_parquet_files()], split)
187
- if ebook_ratio > 0 and not ebook_paths:
188
- raise FileNotFoundError(
189
- f"ebook_ratio > 0 but no {_EBOOK_DATASET_NAME} parquet found. Run:\n"
190
- " python -m nanoai.agentic.data.ebook_data prepare --md-root ~/ebook/markdown"
191
- )
192
-
193
- web_iter = _doc_batches_from(web_paths, ddp_rank, ddp_world_size, tokenizer_batch_size)
194
- code_iter = (
195
- _doc_batches_from(code_paths, ddp_rank, ddp_world_size, tokenizer_batch_size)
196
- if code_paths
197
- else None
198
- )
199
- pcap_iter = (
200
- _doc_batches_from(pcap_paths, ddp_rank, ddp_world_size, tokenizer_batch_size)
201
- if pcap_paths
202
- else None
203
- )
204
- synth_swe_iter = (
205
- _jsonl_doc_batches_from(synth_swe_paths, ddp_rank, ddp_world_size, tokenizer_batch_size)
206
- if synth_swe_paths
207
- else None
208
- )
209
- synth_pleias_iter = (
210
- synth_doc_batches(synth_pleias_paths, ddp_rank, ddp_world_size, tokenizer_batch_size)
211
- if synth_pleias_paths
212
- else None
213
- )
214
- vrcpt_iter = (
215
- _doc_batches_from(vrcpt_paths, ddp_rank, ddp_world_size, tokenizer_batch_size)
216
- if vrcpt_paths
217
- else None
218
- )
219
- ebook_iter = (
220
- _doc_batches_from(ebook_paths, ddp_rank, ddp_world_size, tokenizer_batch_size)
221
- if ebook_paths
222
- else None
223
- )
224
-
225
- if ddp_rank == 0:
226
- web_ratio = max(0.0, 1.0 - code_ratio - pcap_ratio - synth_swe_ratio - synth_pleias_ratio - vrcpt_ratio - ebook_ratio)
227
- print0(
228
- f"[code-mixer] split={split} "
229
- f"ratios web={web_ratio:.2f} code={code_ratio:.2f} pcap={pcap_ratio:.2f} "
230
- f"synth_swe={synth_swe_ratio:.2f} synth_pleias={synth_pleias_ratio:.2f} "
231
- f"vrcpt={vrcpt_ratio:.2f} ebook={ebook_ratio:.2f} "
232
- f"shards web={len(web_paths)} code={len(code_paths)} "
233
- f"pcap={len(pcap_paths)} synth_swe={len(synth_swe_paths)} "
234
- f"synth_pleias={len(synth_pleias_paths)} vrcpt={len(vrcpt_paths)} ebook={len(ebook_paths)}"
235
- )
236
-
237
- rng = random.Random(seed + ddp_rank)
238
- counts = {"web": 0, "code": 0, "pcap": 0, "synth_swe": 0, "synth_pleias": 0, "vrcpt": 0, "ebook": 0}
239
- log_every = 10000
240
- # Decision order: synth_swe, synth_pleias, vrcpt, ebook, pcap, code, web — additive slices of [0,1).
241
- while True:
242
- r = rng.random()
243
- if synth_swe_iter is not None and r < synth_swe_ratio:
244
- counts["synth_swe"] += 1
245
- yield next(synth_swe_iter)
246
- elif synth_pleias_iter is not None and r < synth_swe_ratio + synth_pleias_ratio:
247
- counts["synth_pleias"] += 1
248
- yield next(synth_pleias_iter)
249
- elif vrcpt_iter is not None and r < synth_swe_ratio + synth_pleias_ratio + vrcpt_ratio:
250
- counts["vrcpt"] += 1
251
- yield next(vrcpt_iter)
252
- elif ebook_iter is not None and r < synth_swe_ratio + synth_pleias_ratio + vrcpt_ratio + ebook_ratio:
253
- counts["ebook"] += 1
254
- yield next(ebook_iter)
255
- elif pcap_iter is not None and r < synth_swe_ratio + synth_pleias_ratio + vrcpt_ratio + ebook_ratio + pcap_ratio:
256
- counts["pcap"] += 1
257
- yield next(pcap_iter)
258
- elif code_iter is not None and r < synth_swe_ratio + synth_pleias_ratio + vrcpt_ratio + ebook_ratio + pcap_ratio + code_ratio:
259
- counts["code"] += 1
260
- yield next(code_iter)
261
- else:
262
- counts["web"] += 1
263
- yield next(web_iter)
264
- total = sum(counts.values())
265
- if ddp_rank == 0 and total % log_every == 0:
266
- print0(
267
- f"[code-mixer] draws={total} "
268
- f"web={counts['web']/total:.3f} code={counts['code']/total:.3f} "
269
- f"pcap={counts['pcap']/total:.3f} synth_swe={counts['synth_swe']/total:.3f} "
270
- f"synth_pleias={counts['synth_pleias']/total:.3f} vrcpt={counts['vrcpt']/total:.3f} "
271
- f"ebook={counts['ebook']/total:.3f}"
272
- )
273
-
274
-
275
- def tokenizing_code_mixed_data_loader(
276
- tokenizer,
277
- B: int,
278
- T: int,
279
- split: str,
280
- code_ratio: float = 0.2,
281
- pcap_ratio: float = 0.0,
282
- synth_swe_ratio: float = 0.0,
283
- synth_swe_dir: Path | None = None,
284
- synth_pleias_ratio: float = 0.0,
285
- vrcpt_ratio: float = 0.0,
286
- ebook_ratio: float = 0.0,
287
- tokenizer_threads: int = 4,
288
- tokenizer_batch_size: int = 128,
289
- device: str = "cuda",
290
- buffer_size: int = 1000,
291
- seed: int = 0,
292
- ):
293
- """BOS-aligned best-fit packing over a (web, code, pcap, synth_swe, synth_pleias, vrcpt, ebook) mixture.
294
-
295
- Yields (inputs, targets) GPU tensors of shape (B, T). Drop-in for
296
- nanoai's `tokenizing_distributed_data_loader_bos_bestfit`.
297
- """
298
- assert split in ("train", "val")
299
- row_capacity = T + 1
300
- bos_token = tokenizer.get_bos_token_id()
301
-
302
- batches = _mixed_document_batches(
303
- split, code_ratio, pcap_ratio, tokenizer_batch_size, seed=seed,
304
- synth_swe_ratio=synth_swe_ratio, synth_swe_dir=synth_swe_dir,
305
- synth_pleias_ratio=synth_pleias_ratio,
306
- vrcpt_ratio=vrcpt_ratio,
307
- ebook_ratio=ebook_ratio,
308
- )
309
- doc_buffer: list[list[int]] = []
310
-
311
- def refill_buffer():
312
- doc_batch = next(batches)
313
- token_lists = tokenizer.encode(
314
- doc_batch, prepend=bos_token, num_threads=tokenizer_threads
315
- )
316
- for tokens in token_lists:
317
- doc_buffer.append(tokens)
318
-
319
- use_cuda = device == "cuda"
320
- row_buffer = torch.empty((B, row_capacity), dtype=torch.long)
321
- cpu_buffer = torch.empty(2 * B * T, dtype=torch.long, pin_memory=use_cuda)
322
- gpu_buffer = torch.empty(2 * B * T, dtype=torch.long, device=device)
323
- cpu_inputs = cpu_buffer[: B * T].view(B, T)
324
- cpu_targets = cpu_buffer[B * T :].view(B, T)
325
- inputs = gpu_buffer[: B * T].view(B, T)
326
- targets = gpu_buffer[B * T :].view(B, T)
327
-
328
- while True:
329
- for row_idx in range(B):
330
- pos = 0
331
- while pos < row_capacity:
332
- while len(doc_buffer) < buffer_size:
333
- refill_buffer()
334
- remaining = row_capacity - pos
335
-
336
- best_idx = -1
337
- best_len = 0
338
- for i, doc in enumerate(doc_buffer):
339
- doc_len = len(doc)
340
- if doc_len <= remaining and doc_len > best_len:
341
- best_idx = i
342
- best_len = doc_len
343
-
344
- if best_idx >= 0:
345
- doc = doc_buffer.pop(best_idx)
346
- doc_len = len(doc)
347
- row_buffer[row_idx, pos : pos + doc_len] = torch.tensor(doc, dtype=torch.long)
348
- pos += doc_len
349
- else:
350
- shortest_idx = min(range(len(doc_buffer)), key=lambda i: len(doc_buffer[i]))
351
- doc = doc_buffer.pop(shortest_idx)
352
- row_buffer[row_idx, pos : pos + remaining] = torch.tensor(
353
- doc[:remaining], dtype=torch.long
354
- )
355
- pos += remaining
356
-
357
- cpu_inputs.copy_(row_buffer[:, :-1])
358
- cpu_targets.copy_(row_buffer[:, 1:])
359
- gpu_buffer.copy_(cpu_buffer, non_blocking=use_cuda)
360
- yield inputs, targets
361
-
362
-
363
- def tokenizing_code_mixed_data_loader_with_state(
364
- tokenizer,
365
- B: int,
366
- T: int,
367
- split: str,
368
- code_ratio: float = 0.2,
369
- pcap_ratio: float = 0.0,
370
- synth_swe_ratio: float = 0.0,
371
- synth_swe_dir: Path | None = None,
372
- synth_pleias_ratio: float = 0.0,
373
- vrcpt_ratio: float = 0.0,
374
- ebook_ratio: float = 0.0,
375
- tokenizer_threads: int = 4,
376
- tokenizer_batch_size: int = 128,
377
- device: str = "cuda",
378
- resume_state_dict=None,
379
- buffer_size: int = 1000,
380
- seed: int = 0,
381
- ):
382
- """Same as `tokenizing_code_mixed_data_loader` but yields a third element
383
- `state_dict` to match the signature of nanoai's
384
- `tokenizing_distributed_data_loader_with_state_bos_bestfit`.
385
-
386
- The state_dict is a stub (`{"epoch": 1, "pq_idx": 0, "rg_idx": 0}`) — true
387
- resume across the multi-stream mixture is not implemented. Caller's
388
- `resume_state_dict` is ignored.
389
- """
390
- base = tokenizing_code_mixed_data_loader(
391
- tokenizer, B, T, split,
392
- code_ratio=code_ratio,
393
- pcap_ratio=pcap_ratio,
394
- synth_swe_ratio=synth_swe_ratio,
395
- synth_swe_dir=synth_swe_dir,
396
- synth_pleias_ratio=synth_pleias_ratio,
397
- vrcpt_ratio=vrcpt_ratio,
398
- ebook_ratio=ebook_ratio,
399
- tokenizer_threads=tokenizer_threads,
400
- tokenizer_batch_size=tokenizer_batch_size,
401
- device=device,
402
- buffer_size=buffer_size,
403
- seed=seed,
404
- )
405
- stub_state = {"epoch": 1, "pq_idx": 0, "rg_idx": 0}
406
- for inputs, targets in base:
407
- yield inputs, targets, stub_state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/cross_tokenizer_kd.py DELETED
@@ -1,225 +0,0 @@
1
- """Byte-level alignment between heterogeneous BPE tokenizers (e.g. nanoai vs Qwen).
2
-
3
- Use case: the OPD student emits a sequence of nanoai token ids; we want
4
- per-token teacher logp from a teacher that uses a DIFFERENT BPE tokenizer.
5
-
6
- Approach:
7
- 1. Both tokenizers decode their token sequences to the SAME byte string `s`.
8
- - nanoai: `RustBPETokenizer.enc.decode_single_token_bytes(tid)` returns
9
- the raw bytes for one BPE merge. Concatenating these for the full
10
- sequence reproduces the original byte string (modulo special tokens).
11
- - HF (Qwen): `tokenizer.convert_ids_to_tokens(ids)` gives sub-strings; we
12
- cumulate bytes through `decode(ids)` of each prefix to get offsets.
13
- 2. Each token gets a byte interval `[start, end)` in `s`. We then sum the
14
- teacher (Qwen) per-token logps that fall inside the student token's
15
- interval — by the chain rule, sum of consecutive log p(y_t | y_<t) is the
16
- joint log p over the byte span.
17
-
18
- Edge cases:
19
- - **Special tokens** (`<|tool_call_start|>` etc.): nanoai reserves these as
20
- single ids that decode to multi-char strings (`<|...|>`), which Qwen will
21
- re-tokenize into ~5-8 BPE tokens. The byte interval still covers the same
22
- bytes, so the sum still aligns correctly. The CALLER may mask these
23
- positions out of the OPD loss because the teacher has no special-token
24
- semantic for them — we expose `is_special_token` per offset for that.
25
- - **Misalignment at byte level**: nanoai tokenizer may emit bytes that are
26
- invalid UTF-8 (in the middle of a multi-byte char). When we concat to
27
- build the byte string and Qwen tries to re-tokenize via decode→bytes, the
28
- boundary may shift by a few bytes. We tolerate up to 1-byte mismatch via
29
- fuzzy match in the alignment loop.
30
- """
31
-
32
- from __future__ import annotations
33
-
34
- from dataclasses import dataclass
35
- from typing import List, Optional, Tuple
36
-
37
-
38
- @dataclass
39
- class TokenSpan:
40
- """Byte interval [start, end) and decoded text of one BPE token."""
41
- token_id: int
42
- start: int # byte offset (inclusive)
43
- end: int # byte offset (exclusive)
44
- text: str # decoded bytes (lossy for non-utf8)
45
- is_special: bool
46
-
47
-
48
- def compute_nanochat_offsets(
49
- tokenizer, token_ids: List[int]
50
- ) -> Tuple[List[TokenSpan], bytes]:
51
- """Compute byte offsets for a nanoai token sequence.
52
-
53
- Returns (spans, byte_string) where spans[i] is the TokenSpan for token i.
54
- The byte_string is the concatenation of all per-token bytes — when the
55
- student emitted a "normal" text trace this equals the natural utf-8
56
- encoding of the decoded text.
57
-
58
- For special tokens (BOS, tool_call_start, etc.), we use the rendered
59
- `<|name|>` form so the Qwen tokenizer re-tokenizes them into multiple BPE
60
- units; this is correct as long as the byte boundary aligns to the rendered
61
- form's edges, which it always does because tiktoken stores specials as
62
- these exact ascii strings internally.
63
- """
64
- spans: List[TokenSpan] = []
65
- cur_bytes = bytearray()
66
-
67
- # Qwen path: HF-backed wrapper. id_to_token returns the special-token name
68
- # (e.g. '<|im_start|>') for added tokens, or the BPE piece for regular
69
- # tokens. Decoding the full id list with skip_special_tokens=False produces
70
- # the same string as concatenating per-token decode_single_token; we use
71
- # per-token decode to get accurate byte spans.
72
- if getattr(tokenizer, "chat_kind", None) == "qwen":
73
- hf = tokenizer.tok
74
- added = hf.added_tokens_decoder if hasattr(hf, "added_tokens_decoder") else {}
75
- for tid in token_ids:
76
- start = len(cur_bytes)
77
- if tid in added:
78
- # Added/special token — emit its surface form as bytes.
79
- text = added[tid].content if hasattr(added[tid], "content") else str(added[tid])
80
- tb = text.encode("utf-8")
81
- is_special = True
82
- else:
83
- # Regular BPE token. decode([tid]) returns its surface string.
84
- text = hf.decode([tid], skip_special_tokens=False)
85
- tb = text.encode("utf-8")
86
- is_special = False
87
- cur_bytes.extend(tb)
88
- end = len(cur_bytes)
89
- spans.append(TokenSpan(tid, start, end, text, is_special))
90
- return spans, bytes(cur_bytes)
91
-
92
- # nanoai tiktoken path.
93
- enc = tokenizer.enc # tiktoken.Encoding
94
-
95
- # Build a set of special-token ids for fast lookup.
96
- special_ids = set(enc._special_tokens.values()) if hasattr(enc, "_special_tokens") else set()
97
-
98
- for tid in token_ids:
99
- start = len(cur_bytes)
100
- if tid in special_ids:
101
- # Render the special token as its name string (e.g. "<|bos|>")
102
- # so byte alignment is well-defined.
103
- name = None
104
- for k, v in enc._special_tokens.items():
105
- if v == tid:
106
- name = k
107
- break
108
- text = name or f"<|sp_{tid}|>"
109
- tb = text.encode("utf-8")
110
- is_special = True
111
- else:
112
- tb = enc.decode_single_token_bytes(tid)
113
- try:
114
- text = tb.decode("utf-8")
115
- except UnicodeDecodeError:
116
- text = tb.decode("utf-8", errors="replace")
117
- is_special = False
118
- cur_bytes.extend(tb)
119
- end = len(cur_bytes)
120
- spans.append(TokenSpan(tid, start, end, text, is_special))
121
-
122
- return spans, bytes(cur_bytes)
123
-
124
-
125
- def compute_hf_offsets_from_decoded(
126
- token_ids: List[int], decoded_tokens: List[str]
127
- ) -> List[TokenSpan]:
128
- """Compute byte offsets directly from pre-decoded token strings.
129
-
130
- vLLM's `prompt_logprobs` response includes `decoded_token` per position,
131
- e.g. " capital", " of", " France". We use those directly to build byte
132
- offsets, sidestepping the need for the HF tokenizer to be present locally.
133
-
134
- token_ids and decoded_tokens must be parallel, same length.
135
- """
136
- assert len(token_ids) == len(decoded_tokens)
137
- spans: List[TokenSpan] = []
138
- cur_bytes = bytearray()
139
- for tid, text in zip(token_ids, decoded_tokens):
140
- start = len(cur_bytes)
141
- tb = text.encode("utf-8")
142
- cur_bytes.extend(tb)
143
- end = len(cur_bytes)
144
- spans.append(TokenSpan(tid, start, end, text, False))
145
- return spans
146
-
147
-
148
- def align_logps(
149
- target_spans: List[TokenSpan],
150
- source_spans: List[TokenSpan],
151
- source_logps: List[Optional[float]],
152
- ) -> List[Optional[float]]:
153
- """Distribute source logps proportionally over each target byte interval.
154
-
155
- target_spans: e.g. nanoai student tokens — we want one logp per target.
156
- source_spans: e.g. Qwen teacher tokens — we have one logp per source.
157
- source_logps: list of float (or None for source position 0,
158
- which has no logp because there's no prior context).
159
-
160
- Returns: list of Optional[float], length == len(target_spans). None means
161
- "couldn't compute" (target span overlapping with source[0] which has
162
- no logp, or no source coverage at all).
163
-
164
- Bug-fix note (codex adversarial review 2026-05-18, finding #2):
165
- Previously, when a single source (teacher) token spanned two target
166
- (student) byte ranges, its FULL logp was added to BOTH targets — a
167
- silent 2x double-counting. We now allocate by overlap fraction:
168
- target[i] += source[k].logp × (overlap_bytes / source_byte_length)
169
- This preserves the chain-rule invariant Σ_target_in_S aligned ≈ S.logp.
170
- A target is invalid (None) only if no overlap or any overlapping source
171
- has missing logp.
172
- """
173
- aligned: List[Optional[float]] = [None] * len(target_spans)
174
- n_src = len(source_spans)
175
-
176
- # Two-pointer scan.
177
- j = 0
178
- for i, t in enumerate(target_spans):
179
- # Advance j past any source token strictly before t.
180
- while j < n_src and source_spans[j].end <= t.start:
181
- j += 1
182
- # Distribute source logps that overlap t, weighted by overlap fraction.
183
- total = 0.0
184
- any_overlap = False
185
- any_missing = False
186
- k = j
187
- while k < n_src and source_spans[k].start < t.end:
188
- s = source_spans[k]
189
- ov = min(t.end, s.end) - max(t.start, s.start)
190
- if ov > 0:
191
- if source_logps[k] is None:
192
- any_missing = True
193
- else:
194
- s_len = s.end - s.start
195
- weight = (ov / s_len) if s_len > 0 else 1.0
196
- total += source_logps[k] * weight
197
- any_overlap = True
198
- k += 1
199
- if any_overlap and not any_missing:
200
- aligned[i] = total
201
- else:
202
- aligned[i] = None # either no coverage or hit the unknown source[0]
203
- return aligned
204
-
205
-
206
- def align_qwen_logp_to_nano(
207
- nano_tokenizer,
208
- nano_token_ids: List[int],
209
- qwen_token_ids: List[int],
210
- qwen_decoded_tokens: List[str],
211
- qwen_logps: List[Optional[float]],
212
- ) -> Tuple[List[Optional[float]], List[bool]]:
213
- """One-call convenience wrapper.
214
-
215
- Returns (aligned_logps, is_special_mask) for the nanoai sequence.
216
- aligned_logps[i] = sum of qwen_logps over qwen tokens that cover nano
217
- token i's bytes, or None if can't align.
218
- is_special_mask[i] = True iff nano token i is a special token (caller
219
- should typically mask these out of OPD loss).
220
- """
221
- nano_spans, _ = compute_nanochat_offsets(nano_tokenizer, nano_token_ids)
222
- qwen_spans = compute_hf_offsets_from_decoded(qwen_token_ids, qwen_decoded_tokens)
223
- aligned = align_logps(nano_spans, qwen_spans, qwen_logps)
224
- specials = [s.is_special for s in nano_spans]
225
- return aligned, specials
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/dapt_probes.py DELETED
@@ -1,194 +0,0 @@
1
- """DAPT/CPT in-training probes shared with `scripts.base_train`.
2
-
3
- Pure functions kept outside the training script so they can be unit-tested
4
- without booting the full DDP / NVFP4 / wandb stack. Each helper is small,
5
- side-effect-free except for the obvious (parquet I/O, model.eval()), and
6
- exposes verbose `print0` logging so an operator watching the train log can
7
- reconstruct exactly what the probe did.
8
-
9
- Three helpers:
10
-
11
- - `build_domain_val_loader(domain, ...)` — return a single-source val loader
12
- over web | code | pcap, regardless of the active training mix. Used to
13
- compute per-domain BPB during eval ticks.
14
-
15
- - `load_l0_tasks(repo_root)` — read tshark_eval_agentic L0 vocab tasks. Memo-
16
- izes the parse so repeated probes don't re-read the JSONL.
17
-
18
- - `run_l0_probe(model_for_gen, tokenizer, ..., disable_fp8_ctx=...)` — greedy
19
- decode N L0 questions with a 3-shot prefix, return string-match accuracy.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import json
25
- import random
26
- import time
27
- from contextlib import nullcontext
28
- from pathlib import Path
29
- from typing import Callable, ContextManager, Optional
30
-
31
- from nanoai.common import print0
32
-
33
-
34
- # -----------------------------------------------------------------------------
35
- # Per-domain val loader
36
-
37
- DOMAIN_RATIOS = {
38
- "web": {"code_ratio": 0.0, "pcap_ratio": 0.0},
39
- "code": {"code_ratio": 1.0, "pcap_ratio": 0.0},
40
- "pcap": {"code_ratio": 0.0, "pcap_ratio": 1.0},
41
- }
42
-
43
-
44
- def build_domain_val_loader(domain: str, tokenizer, device_batch_size: int,
45
- max_seq_len: int, device: str):
46
- """Construct a single-source val loader for {web, code, pcap}.
47
-
48
- Bypasses any monkey-patched train loader so per-domain BPB is unaffected by
49
- the active training mixture. Returns the same yield shape as the patched
50
- loader (yields (inputs, targets) — no state_dict).
51
- """
52
- assert domain in DOMAIN_RATIOS, (
53
- f"unknown domain {domain!r}, expected one of {sorted(DOMAIN_RATIOS)}"
54
- )
55
- # Local import: code_dataloader pulls in pyarrow, torch, etc. — keep that
56
- # cost off the import path of any caller that doesn't actually probe.
57
- from nanoai.agentic.code_dataloader import tokenizing_code_mixed_data_loader
58
-
59
- ratios = DOMAIN_RATIOS[domain]
60
- print0(f"[dapt-probe] building val loader for domain={domain!r} ratios={ratios}")
61
- return tokenizing_code_mixed_data_loader(
62
- tokenizer, device_batch_size, max_seq_len, split="val",
63
- code_ratio=ratios["code_ratio"],
64
- pcap_ratio=ratios["pcap_ratio"],
65
- device=device,
66
- )
67
-
68
-
69
- # -----------------------------------------------------------------------------
70
- # L0 probe
71
-
72
- _DEFAULT_L0_PATH = Path(__file__).resolve().parents[2] / "tshark_eval_agentic" / "eval" / "tasks_l0_vocab.jsonl"
73
-
74
- _L0_CACHE: dict[Path, list[dict]] = {}
75
-
76
-
77
- def load_l0_tasks(path: Optional[Path] = None) -> list[dict]:
78
- """Read experiments/tshark_eval_agentic/eval/tasks_l0_vocab.jsonl into a list of dicts.
79
-
80
- Caches per-path so repeated probes don't re-read the file. Returns [] if
81
- the file is missing (probe is then a no-op).
82
- """
83
- p = path if path is not None else _DEFAULT_L0_PATH
84
- p = Path(p)
85
- if p in _L0_CACHE:
86
- return _L0_CACHE[p]
87
- if not p.exists():
88
- print0(f"[dapt-probe] L0 task file not found at {p}; probe will be a no-op.")
89
- _L0_CACHE[p] = []
90
- return _L0_CACHE[p]
91
- tasks: list[dict] = []
92
- with p.open("r", encoding="utf-8") as f:
93
- for line_no, line in enumerate(f, 1):
94
- line = line.strip()
95
- if not line:
96
- continue
97
- try:
98
- obj = json.loads(line)
99
- except json.JSONDecodeError as e:
100
- print0(f"[dapt-probe] L0 task file line {line_no} not JSON: {e}; skipping.")
101
- continue
102
- if "prompt" in obj and "expected" in obj:
103
- tasks.append(obj)
104
- print0(f"[dapt-probe] loaded {len(tasks)} L0 tasks from {p}")
105
- _L0_CACHE[p] = tasks
106
- return tasks
107
-
108
-
109
- L0_FEW_SHOT = (
110
- "Q: What is the tshark display-filter field name for: Source IP address?\nA: ip.src\n"
111
- "Q: What is the tshark display-filter field name for: TCP destination port?\nA: tcp.dstport\n"
112
- "Q: What is the tshark display-filter field name for: HTTP request method?\nA: http.request.method\n"
113
- )
114
-
115
-
116
- def _first_line(text: str) -> str:
117
- """Return the first non-empty line of `text`, stripped."""
118
- for line in text.strip().splitlines():
119
- s = line.strip()
120
- if s:
121
- return s
122
- return ""
123
-
124
-
125
- def l0_match(expected: str, generation: str) -> bool:
126
- """Lenient match: `expected` (case-insensitive) appears in the first
127
- generated line. Mirrors `experiments/tshark_eval_agentic/eval/grader.exact_str_lower`
128
- behaviour for the vocab tasks (which always ask for a single token like
129
- `tcp.flags.syn`).
130
- """
131
- return expected.lower() in _first_line(generation).lower()
132
-
133
-
134
- def run_l0_probe(
135
- model_for_gen,
136
- tokenizer,
137
- Engine,
138
- num_questions: int,
139
- max_tokens: int,
140
- disable_fp8_ctx: Optional[Callable[[object], ContextManager]] = None,
141
- seed: int = 42,
142
- debug: bool = False,
143
- task_path: Optional[Path] = None,
144
- ) -> Optional[float]:
145
- """Greedy-decode N L0 vocab questions and return string-match accuracy.
146
-
147
- Returns None if no L0 tasks file is available, else accuracy in [0,1].
148
-
149
- `Engine` is passed in (rather than imported) so this function stays
150
- importable without booting nanoai.engine — the test path can pass a
151
- stub Engine.
152
- `disable_fp8_ctx` is the context manager factory (usually `disable_fp8`
153
- from base_train.py); pass None to disable the swap.
154
- """
155
- tasks = load_l0_tasks(task_path)
156
- if not tasks:
157
- return None
158
- rng = random.Random(seed)
159
- sample = rng.sample(tasks, min(num_questions, len(tasks)))
160
- print0(
161
- f"[dapt-probe] running L0 probe: {len(sample)} questions, "
162
- f"max_tokens={max_tokens}, seed={seed}"
163
- )
164
-
165
- engine = Engine(model_for_gen, tokenizer)
166
- correct = 0
167
- t0 = time.time()
168
- bos = "<|bos|>"
169
- ctx_factory = disable_fp8_ctx if disable_fp8_ctx is not None else (lambda _m: nullcontext())
170
- for i, t in enumerate(sample):
171
- prompt_text = L0_FEW_SHOT + f"Q: {t['prompt']}\nA:"
172
- ids = tokenizer(prompt_text, prepend=bos)
173
- with ctx_factory(model_for_gen):
174
- out, _ = engine.generate_batch(
175
- ids, num_samples=1, max_tokens=max_tokens, temperature=0,
176
- )
177
- gen_ids = out[0][len(ids):] if len(out[0]) > len(ids) else out[0]
178
- gen_text = tokenizer.decode(gen_ids)
179
- hit = l0_match(t["expected"], gen_text)
180
- if hit:
181
- correct += 1
182
- if debug:
183
- print0(
184
- f"[dapt-probe][L0 {i+1:02d}/{len(sample)}] "
185
- f"expected={t['expected']!r} gen_first_line={_first_line(gen_text)!r} "
186
- f"{'✓' if hit else '✗'}"
187
- )
188
- dt = time.time() - t0
189
- acc = correct / len(sample)
190
- print0(
191
- f"[dapt-probe] L0 probe done: {correct}/{len(sample)} = {acc:.3f} "
192
- f"({dt:.1f}s, {dt/len(sample):.2f}s/q)"
193
- )
194
- return acc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/__init__.py DELETED
@@ -1 +0,0 @@
1
- """Conversation/preference datasets used by agentic SFT and DPO."""
 
 
vendor/nanoai/agentic/data/codedebug.py DELETED
@@ -1,359 +0,0 @@
1
- """Multi-turn execute-debug datasets for v5 SFT.
2
-
3
- Goal: give the model real `tool_call → observation → tool_call → ...` loops
4
- beyond tulu's Edit-only tool-call shape (the v4 Edit-bias root cause).
5
-
6
- Two sources, both folded into the agentic chat format used by
7
- `render_agentic_conversation`'s list-content branch:
8
-
9
- - `xingyaoww/code-act` (codeact split): assistant emits `<execute>...</execute>`,
10
- user emits `Observation: ...`. ~7k rows.
11
- - `m-a-p/Code-Feedback` (filtered to python with exec output): assistant emits
12
- ` ```python ... ``` `, user emits `Execution result: ...`. ~10k after filter.
13
-
14
- Output row shape (per `__getitem__`):
15
- {"messages": [
16
- {"role": "system", "content": SYSTEM_PROMPT},
17
- {"role": "user", "content": "..."},
18
- {"role": "assistant", "content": [{"type":"text"|"python"|"python_output", "text": ...}, ...]},
19
- ...
20
- ]}
21
-
22
- The list-content branch wraps `python` segments in `<|python_start|>/<|python_end|>`
23
- (supervised) and `python_output` in `<|output_start|>/<|output_end|>` (unsupervised).
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import os
29
- import random
30
- import re
31
- from typing import Any, Iterable
32
-
33
- from datasets import load_dataset
34
-
35
- from nanoai.agentic.data.common import SYSTEM_PROMPT
36
-
37
-
38
- # ---------------------------------------------------------------------------
39
- # Parsers
40
- # ---------------------------------------------------------------------------
41
-
42
- # Code-act assistant action: <execute> ... </execute>. Solutions stay as text.
43
- _EXECUTE_RE = re.compile(r"<execute>(.*?)</execute>", re.DOTALL)
44
-
45
- # Code-Feedback assistant action: ```python ... ``` (case-insensitive, leading ws ok).
46
- # We deliberately match only python (Ruby/JS/etc. blocks stay as text — caller
47
- # filters those rows out earlier so they don't end up here).
48
- _PYBLOCK_RE = re.compile(r"```\s*[Pp]ython\s*\n(.*?)```", re.DOTALL)
49
-
50
- # Observation prefixes folded into the preceding assistant turn as python_output.
51
- _OBS_PREFIXES = (
52
- "observation:",
53
- "execution result:",
54
- "execution output:",
55
- "stdout:",
56
- )
57
-
58
- # Skip Code-Feedback rows whose "execution" is actually a Jupyter cell error trace —
59
- # those teach the model how-to-format-a-traceback, not how-to-debug.
60
- _JUPYTER_ERROR_MARKERS = ("Cell In[", "<ipython-input-", "ipykernel_")
61
-
62
- # Skip rows with non-python code blocks above a threshold (Code-Feedback contains
63
- # Ruby/JS/SQL etc.). Cheap heuristic: look for ```ruby/```javascript/etc. in any
64
- # assistant content; if present and ```python is absent, skip.
65
- _NON_PYTHON_BLOCK_RE = re.compile(
66
- r"```\s*(?:ruby|javascript|js|java|c\+\+|cpp|sql|go|rust|php|kotlin|scala)\b",
67
- re.IGNORECASE,
68
- )
69
-
70
-
71
- def _split_assistant_text(text: str, code_re: re.Pattern[str]) -> list[dict[str, str]]:
72
- """Split an assistant message into ordered text/python parts.
73
-
74
- The regex matches code-fenced or tag-fenced spans; everything else stays text.
75
- Returns [] if the input is empty after stripping.
76
- """
77
- if not text or not text.strip():
78
- return []
79
- parts: list[dict[str, str]] = []
80
- pos = 0
81
- for m in code_re.finditer(text):
82
- head = text[pos : m.start()]
83
- if head.strip():
84
- parts.append({"type": "text", "text": head.strip()})
85
- body = m.group(1).strip("\n").rstrip()
86
- if body:
87
- parts.append({"type": "python", "text": body})
88
- pos = m.end()
89
- tail = text[pos:]
90
- if tail.strip():
91
- parts.append({"type": "text", "text": tail.strip()})
92
- return parts
93
-
94
-
95
- def _extract_observation(content: str) -> str | None:
96
- """If content is a pure observation, return the body sans prefix; else None."""
97
- if not content:
98
- return None
99
- stripped = content.lstrip()
100
- lower = stripped.lower()
101
- for prefix in _OBS_PREFIXES:
102
- if lower.startswith(prefix):
103
- return stripped[len(prefix):].lstrip("\n").rstrip() or " "
104
- return None
105
-
106
-
107
- def _fold_into_assistant(messages: list[dict[str, Any]], code_re: re.Pattern[str]) -> list[dict[str, Any]]:
108
- """Walk raw user/assistant alternation; return the list-content cleaned form.
109
-
110
- Rules:
111
- * Drop system messages — caller injects SYSTEM_PROMPT.
112
- * Assistant with code blocks → list-content [{text|python} parts].
113
- * Assistant without code → plain str content.
114
- * User that is purely an observation following an assistant with code →
115
- appended as a {type: python_output} part to the preceding assistant.
116
- * Anything else stays user/assistant.
117
- * Drop trailing user-only turns (no completion to supervise).
118
- * Drop rows whose first non-system role isn't user.
119
- """
120
- cleaned: list[dict[str, Any]] = []
121
-
122
- for msg in messages:
123
- role = msg.get("role")
124
- content = msg.get("content", "") or ""
125
- if role == "system":
126
- continue
127
-
128
- if role == "assistant":
129
- parts = _split_assistant_text(content, code_re)
130
- if not parts:
131
- continue
132
- if any(p["type"] == "python" for p in parts):
133
- cleaned.append({"role": "assistant", "content": parts})
134
- else:
135
- # Plain prose answer — keep as a single string for the simpler
136
- # render path. Re-join the text parts.
137
- cleaned.append({"role": "assistant", "content": " ".join(p["text"] for p in parts)})
138
- continue
139
-
140
- if role == "user":
141
- obs = _extract_observation(content)
142
- if (
143
- obs is not None
144
- and cleaned
145
- and cleaned[-1]["role"] == "assistant"
146
- and isinstance(cleaned[-1]["content"], list)
147
- and any(p["type"] == "python" for p in cleaned[-1]["content"])
148
- ):
149
- cleaned[-1]["content"].append({"type": "python_output", "text": obs})
150
- continue
151
- cleaned.append({"role": "user", "content": content.strip()})
152
- continue
153
-
154
- # Unknown role (tool_result, function, etc.) — skip silently.
155
-
156
- # Drop leading non-user turns and trailing non-assistant turns.
157
- while cleaned and cleaned[0]["role"] != "user":
158
- cleaned.pop(0)
159
- while cleaned and cleaned[-1]["role"] != "assistant":
160
- cleaned.pop()
161
-
162
- # Folding observations leaves consecutive assistants (the original
163
- # post-observation assistant continuation). Same can happen with consecutive
164
- # users that don't satisfy the observation-fold criterion. Merge them.
165
- return _merge_consecutive_same_role(cleaned)
166
-
167
-
168
- def _merge_consecutive_same_role(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
169
- """Merge runs of same-role messages into one. Strings → list-content if needed."""
170
- out: list[dict[str, Any]] = []
171
- for m in messages:
172
- if out and out[-1]["role"] == m["role"]:
173
- prev = out[-1]
174
- if m["role"] == "user":
175
- prev_text = prev["content"] if isinstance(prev["content"], str) else " ".join(
176
- p["text"] for p in prev["content"]
177
- )
178
- cur_text = m["content"] if isinstance(m["content"], str) else " ".join(
179
- p["text"] for p in m["content"]
180
- )
181
- prev["content"] = (prev_text + "\n\n" + cur_text).strip()
182
- continue
183
- # assistant: prefer list-content if either side already is.
184
- prev_content = prev["content"]
185
- cur_content = m["content"]
186
- if isinstance(prev_content, str) and isinstance(cur_content, str):
187
- prev["content"] = (prev_content + " " + cur_content).strip()
188
- continue
189
- if isinstance(prev_content, str):
190
- prev_content = [{"type": "text", "text": prev_content}] if prev_content.strip() else []
191
- if isinstance(cur_content, str):
192
- cur_content = [{"type": "text", "text": cur_content}] if cur_content.strip() else []
193
- prev["content"] = list(prev_content) + list(cur_content)
194
- continue
195
- out.append(m)
196
- return out
197
-
198
-
199
- def parse_codeact_row(row: dict[str, Any], max_chars: int = 16384) -> dict[str, Any] | None:
200
- """Convert a single code-act row to {messages: [...]} or None to skip."""
201
- raw = row.get("conversations") or []
202
- if not raw:
203
- return None
204
- messages = _fold_into_assistant(raw, _EXECUTE_RE)
205
- if not _is_well_formed(messages, max_chars):
206
- return None
207
- return {"messages": messages}
208
-
209
-
210
- def parse_codefeedback_row(row: dict[str, Any], max_chars: int = 16384) -> dict[str, Any] | None:
211
- """Convert a single Code-Feedback row to {messages: [...]} or None to skip."""
212
- raw = row.get("messages") or []
213
- if not raw:
214
- return None
215
- full_text = "\n".join((m.get("content") or "") for m in raw)
216
- if len(full_text) > max_chars:
217
- return None
218
- if any(marker in full_text for marker in _JUPYTER_ERROR_MARKERS):
219
- return None
220
- if "execution result" not in full_text.lower() and "execution output" not in full_text.lower():
221
- return None
222
- if _NON_PYTHON_BLOCK_RE.search(full_text) and "```python" not in full_text.lower():
223
- return None
224
- messages = _fold_into_assistant(raw, _PYBLOCK_RE)
225
- if not _is_well_formed(messages, max_chars):
226
- return None
227
- # Require at least one observation actually got folded in — otherwise this
228
- # is just chat about code, not execute-debug.
229
- if not _has_python_output(messages):
230
- return None
231
- return {"messages": messages}
232
-
233
-
234
- def _is_well_formed(messages: list[dict[str, Any]], max_chars: int) -> bool:
235
- if len(messages) < 2:
236
- return False
237
- # Strict alternation user/assistant.
238
- for i, m in enumerate(messages):
239
- expected = "user" if i % 2 == 0 else "assistant"
240
- if m["role"] != expected:
241
- return False
242
- if messages[-1]["role"] != "assistant":
243
- return False
244
- total = 0
245
- for m in messages:
246
- c = m["content"]
247
- if isinstance(c, str):
248
- total += len(c)
249
- else:
250
- total += sum(len(p["text"]) for p in c)
251
- return total <= max_chars
252
-
253
-
254
- def _has_python_output(messages: list[dict[str, Any]]) -> bool:
255
- for m in messages:
256
- if m["role"] == "assistant" and isinstance(m["content"], list):
257
- if any(p["type"] == "python_output" for p in m["content"]):
258
- return True
259
- return False
260
-
261
-
262
- # ---------------------------------------------------------------------------
263
- # Dataset wrappers (HF-backed)
264
- # ---------------------------------------------------------------------------
265
-
266
-
267
- def _set_hf_endpoint() -> None:
268
- """If HF_ENDPOINT isn't set and the public hub is unreachable, fall back to
269
- hf-mirror.com. We don't probe network here — caller is expected to set
270
- HF_ENDPOINT in env when behind a firewall."""
271
- # Intentionally a no-op; documented for callers.
272
- return
273
-
274
-
275
- class CodeActDataset:
276
- """xingyaoww/code-act, codeact split — multi-turn data analysis with sqlite."""
277
-
278
- def __init__(self, split: str = "codeact", seed: int = 42, max_chars: int = 16384) -> None:
279
- _set_hf_endpoint()
280
- ds = load_dataset("xingyaoww/code-act", split=split)
281
- self.parsed: list[dict[str, Any]] = []
282
- n_skipped = 0
283
- for row in ds:
284
- parsed = parse_codeact_row(row, max_chars=max_chars)
285
- if parsed is None:
286
- n_skipped += 1
287
- continue
288
- self.parsed.append(parsed)
289
- random.Random(seed).shuffle(self.parsed)
290
- self._n_skipped = n_skipped
291
-
292
- def __len__(self) -> int:
293
- return len(self.parsed)
294
-
295
- def __getitem__(self, idx: int) -> dict[str, Any]:
296
- row = self.parsed[idx]
297
- return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + row["messages"]}
298
-
299
-
300
- class CodeFeedbackDataset:
301
- """m-a-p/Code-Feedback filtered to python rows with execution feedback."""
302
-
303
- def __init__(
304
- self,
305
- split: str = "train",
306
- seed: int = 42,
307
- max_chars: int = 16384,
308
- max_rows: int | None = 10_000,
309
- ) -> None:
310
- _set_hf_endpoint()
311
- ds = load_dataset("m-a-p/Code-Feedback", split=split)
312
- self.parsed: list[dict[str, Any]] = []
313
- n_skipped = 0
314
- for row in ds:
315
- parsed = parse_codefeedback_row(row, max_chars=max_chars)
316
- if parsed is None:
317
- n_skipped += 1
318
- continue
319
- self.parsed.append(parsed)
320
- if max_rows is not None and len(self.parsed) >= max_rows:
321
- break
322
- random.Random(seed).shuffle(self.parsed)
323
- self._n_skipped = n_skipped
324
-
325
- def __len__(self) -> int:
326
- return len(self.parsed)
327
-
328
- def __getitem__(self, idx: int) -> dict[str, Any]:
329
- row = self.parsed[idx]
330
- return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + row["messages"]}
331
-
332
-
333
- # ---------------------------------------------------------------------------
334
- # In-memory variant for unit tests (no HF dep)
335
- # ---------------------------------------------------------------------------
336
-
337
-
338
- class _InMemoryCodeDataset:
339
- """Wraps a pre-built list of {messages: [...]} rows; used by tests."""
340
-
341
- def __init__(self, rows: Iterable[dict[str, Any]], seed: int = 42) -> None:
342
- self.parsed = list(rows)
343
- random.Random(seed).shuffle(self.parsed)
344
-
345
- def __len__(self) -> int:
346
- return len(self.parsed)
347
-
348
- def __getitem__(self, idx: int) -> dict[str, Any]:
349
- row = self.parsed[idx]
350
- return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + row["messages"]}
351
-
352
-
353
- __all__ = [
354
- "CodeActDataset",
355
- "CodeFeedbackDataset",
356
- "parse_codeact_row",
357
- "parse_codefeedback_row",
358
- "_InMemoryCodeDataset",
359
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/common.py DELETED
@@ -1,17 +0,0 @@
1
- SYSTEM_PROMPT = """you are nanocode, a coding agent.
2
- you communicate in lowercase and think out loud before acting.
3
-
4
- your tools enable you to interact with the user's UNIX system and modify and create new files:
5
- Read({"file_path", "offset"?, "limit"?}) - read a file
6
- Edit({"file_path", "old_string"?, "new_string"}) - edit or create a file
7
- Grep({"pattern", "path"?}) - search file contents
8
- Bash({"command"}) - execute a shell command
9
-
10
- emit a tool call by producing exactly one tool-call token block per assistant turn. you've seen the structure during training; reproduce it precisely."""
11
-
12
-
13
- def render_mc(question, letters, choices):
14
- query = f"Multiple Choice question: {question}\n"
15
- query += "".join([f"- {choice}={letter}\n" for letter, choice in zip(letters, choices)])
16
- query += "\nRespond only with the letter of the correct answer."
17
- return query
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/ebook_data.py DELETED
@@ -1,201 +0,0 @@
1
- """Ebook pretrain stream (PDF→markdown textbook corpus).
2
-
3
- Materializes the converted-ebook markdown under ``~/ebook/markdown/**/*.md``
4
- (produced by ~/ebook/convert_ebooks.py, pdftext backend) into parquet shards
5
- so the existing parquet-iter API drives it identically to web / code / pcap.
6
-
7
- Each markdown book is **chunked** into ~``chunk_chars``-sized documents at
8
- paragraph boundaries, so that the code-mixer's per-document categorical draw
9
- approximates a per-token mixture: climbmix web docs average ~2875 chars
10
- (~720 tok), so the default 3000-char chunk keeps ebook docs the same scale and
11
- ``--ebook-ratio 0.5`` ≈ 50% of *tokens* (not just 50% of documents).
12
-
13
- Image references (``![](_page_N_Picture_M.jpeg)``) are stripped — they are
14
- pure-noise placeholders for figures pdftext could not OCR.
15
-
16
- Stored under ``$NANOAI_BASE_DIR/code_pretrain_data/ebook-md/``. Used via
17
- `agentic_pretrain.py --ebook-ratio`. Mirrors `vrcpt_data` (atomic .tmp→rename,
18
- restart-safe validate/quarantine, last shard = val split).
19
-
20
- Run once before pretraining:
21
-
22
- python -m nanoai.agentic.data.ebook_data prepare \\
23
- --md-root ~/ebook/markdown --chunk-chars 3000 --rows-per-shard 50000
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import argparse
29
- import os
30
- import random
31
- import re
32
- import time
33
- from pathlib import Path
34
-
35
- import pyarrow as pa
36
- import pyarrow.parquet as pq
37
-
38
- from nanoai.common import print0
39
- from nanoai.agentic.data.pretrain_data import DATA_DIR
40
-
41
- DATASET_NAME = "ebook-md"
42
- _TMP_SUFFIX = ".parquet.tmp"
43
-
44
- # image-reference lines like ![](_page_0_Picture_1.jpeg) or ![alt](path.png)
45
- _IMG_RE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
46
- # 3+ consecutive blank lines → 2
47
- _BLANKS_RE = re.compile(r"\n{3,}")
48
-
49
-
50
- def ebook_corpus_dir() -> Path:
51
- return DATA_DIR / DATASET_NAME
52
-
53
-
54
- def list_ebook_parquet_files() -> list[Path]:
55
- d = ebook_corpus_dir()
56
- if not d.exists():
57
- return []
58
- return sorted([d / f.name for f in d.iterdir() if f.suffix == ".parquet"])
59
-
60
-
61
- def _clean(text: str) -> str:
62
- text = _IMG_RE.sub("", text)
63
- text = _BLANKS_RE.sub("\n\n", text)
64
- return text.strip()
65
-
66
-
67
- def _chunk_markdown(text: str, chunk_chars: int):
68
- """Split cleaned markdown into ~chunk_chars docs at paragraph boundaries."""
69
- paras = text.split("\n\n")
70
- buf, size = [], 0
71
- for p in paras:
72
- p = p.strip()
73
- if not p:
74
- continue
75
- buf.append(p)
76
- size += len(p) + 2
77
- if size >= chunk_chars:
78
- yield "\n\n".join(buf)
79
- buf, size = [], 0
80
- if buf:
81
- joined = "\n\n".join(buf)
82
- if len(joined) >= 200: # drop tiny trailing fragments
83
- yield joined
84
-
85
-
86
- def _stream_chunks(md_root: Path, chunk_chars: int, seed: int):
87
- files = sorted(md_root.rglob("*.md"))
88
- rng = random.Random(seed)
89
- rng.shuffle(files) # book-level shuffle so shards aren't single-book
90
- for fp in files:
91
- try:
92
- raw = fp.read_text(encoding="utf-8", errors="ignore")
93
- except Exception as e:
94
- print0(f"[ebook_data] skip {fp.name}: {e}")
95
- continue
96
- cleaned = _clean(raw)
97
- if not cleaned:
98
- continue
99
- for chunk in _chunk_markdown(cleaned, chunk_chars):
100
- yield chunk
101
-
102
-
103
- def _shuffled(stream, buffer_size: int, seed: int):
104
- """Reservoir-style shuffle buffer to decorrelate adjacent same-book chunks."""
105
- rng = random.Random(seed + 1)
106
- buf: list[str] = []
107
- for item in stream:
108
- buf.append(item)
109
- if len(buf) >= buffer_size:
110
- j = rng.randrange(len(buf))
111
- buf[j], buf[-1] = buf[-1], buf[j]
112
- yield buf.pop()
113
- rng.shuffle(buf)
114
- yield from buf
115
-
116
-
117
- def _try_open_shard(path: Path) -> bool:
118
- try:
119
- return pq.ParquetFile(path).num_row_groups > 0
120
- except Exception:
121
- return False
122
-
123
-
124
- def prepare(md_root: Path, rows_per_shard: int = 50000, chunk_chars: int = 3000,
125
- shuffle_buffer: int = 50000, seed: int = 1234):
126
- """One-shot markdown → parquet shards under ebook_corpus_dir().
127
-
128
- Atomic write (.tmp → rename), restart-safe. Last shard = val split.
129
- """
130
- out_dir = ebook_corpus_dir()
131
- out_dir.mkdir(parents=True, exist_ok=True)
132
-
133
- existing = sorted(p for p in out_dir.iterdir() if p.suffix == ".parquet")
134
- tmps = [p for p in out_dir.iterdir() if p.name.endswith(_TMP_SUFFIX)]
135
- if existing and not tmps and all(_try_open_shard(p) for p in existing) and len(existing) >= 2:
136
- print0(f"[ebook_data] {len(existing)} valid shards already in {out_dir}; skipping.")
137
- return existing
138
- if existing or tmps:
139
- attic = out_dir / f".attic_{int(time.time())}"
140
- attic.mkdir()
141
- for p in existing + tmps:
142
- p.rename(attic / p.name)
143
- print0(f"[ebook_data] quarantined {len(existing)+len(tmps)} stale file(s) to {attic.name}/")
144
-
145
- assert md_root.exists(), f"md-root not found: {md_root}"
146
- print0(f"[ebook_data] reading {md_root} → {out_dir} "
147
- f"(chunk={chunk_chars} chars, {rows_per_shard} rows/shard)")
148
-
149
- shard_idx = 0
150
- buf: list[str] = []
151
- written: list[Path] = []
152
- total_docs = 0
153
- total_chars = 0
154
-
155
- def flush():
156
- nonlocal shard_idx, buf
157
- if not buf:
158
- return
159
- shard_path = out_dir / f"shard_{shard_idx:05d}.parquet"
160
- tmp_path = shard_path.with_name(shard_path.name + ".tmp")
161
- shard_chars = sum(len(t) for t in buf)
162
- pq.write_table(pa.table({"text": buf}), tmp_path, compression="snappy")
163
- tmp_path.rename(shard_path)
164
- written.append(shard_path)
165
- print0(f"[ebook_data] wrote {shard_path.name} "
166
- f"({len(buf)} docs, {shard_chars/1e6:.2f}M chars, "
167
- f"{shard_path.stat().st_size/1e6:.2f} MB)")
168
- shard_idx += 1
169
- buf = []
170
-
171
- stream = _stream_chunks(md_root, chunk_chars, seed)
172
- for text in _shuffled(stream, shuffle_buffer, seed):
173
- buf.append(text)
174
- total_docs += 1
175
- total_chars += len(text)
176
- if len(buf) >= rows_per_shard:
177
- flush()
178
- flush()
179
-
180
- print0(f"[ebook_data] DONE: {total_docs:,} docs, {total_chars/1e6:.1f}M chars "
181
- f"(~{total_chars/4/1e6:.0f}M est tokens) across {len(written)} shards")
182
- return written
183
-
184
-
185
- def main():
186
- ap = argparse.ArgumentParser(description="Prepare ebook markdown pretrain shards")
187
- sub = ap.add_subparsers(dest="cmd", required=True)
188
- p = sub.add_parser("prepare")
189
- p.add_argument("--md-root", type=str, default=str(Path.home() / "ebook" / "markdown"))
190
- p.add_argument("--rows-per-shard", type=int, default=50000)
191
- p.add_argument("--chunk-chars", type=int, default=3000)
192
- p.add_argument("--shuffle-buffer", type=int, default=50000)
193
- p.add_argument("--seed", type=int, default=1234)
194
- args = ap.parse_args()
195
- if args.cmd == "prepare":
196
- prepare(Path(os.path.expanduser(args.md_root)), args.rows_per_shard,
197
- args.chunk_chars, args.shuffle_buffer, args.seed)
198
-
199
-
200
- if __name__ == "__main__":
201
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/hf_dataset.py DELETED
@@ -1,15 +0,0 @@
1
- """Generic Hugging Face conversation dataset loader."""
2
-
3
- from datasets import load_dataset
4
-
5
-
6
- class HuggingFaceDataset:
7
- def __init__(self, dataset: str, messages_key: str, split: str, seed: int, **kwargs):
8
- self.messages_key = messages_key
9
- self.ds = load_dataset(dataset, split=split, **kwargs).shuffle(seed=seed)
10
-
11
- def __len__(self):
12
- return len(self.ds)
13
-
14
- def __getitem__(self, idx: int):
15
- return {"messages": self.ds[idx][self.messages_key]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/json_dataset.py DELETED
@@ -1,73 +0,0 @@
1
- """JSONL conversation / preference datasets.
2
-
3
- Conversations are tagged with the agentic SYSTEM_PROMPT so the rendered tool-call
4
- schema is always in context. Format on disk matches nanocode rollouts:
5
- {"messages": [{"role": "user", ...}, {"role": "assistant", ...}, ...]}
6
- {"chosen": {"messages": [...]}, "rejected": {"messages": [...]}} # for prefs
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import json
12
- import random
13
-
14
- from nanoai.agentic.data.common import SYSTEM_PROMPT
15
-
16
-
17
- class JSONDataset:
18
- def __init__(self, filepath, seed, **kwargs):
19
- super().__init__(**kwargs)
20
- self.filepath = filepath
21
- self.ds = []
22
- with open(filepath, "r", encoding="utf-8") as f:
23
- for line in f:
24
- line = line.strip()
25
- if not line:
26
- continue
27
- self.ds.append(json.loads(line))
28
- random.Random(seed).shuffle(self.ds)
29
-
30
- def __len__(self):
31
- return len(self.ds)
32
-
33
- def __getitem__(self, idx: int):
34
- messages = self.ds[idx]["messages"]
35
- messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages
36
- return {"messages": messages}
37
-
38
-
39
- class PairedJSONPreferenceDataset:
40
- """Preference pairs filtered to require non-empty assistant content on both sides."""
41
-
42
- def __init__(self, filepath, seed, frac=1.0, **kwargs):
43
- super().__init__(**kwargs)
44
- self.ds = []
45
- with open(filepath, "r", encoding="utf-8") as f:
46
- for line in f:
47
- line = line.strip()
48
- if not line:
49
- continue
50
- row = json.loads(line)
51
- has_chosen = any(
52
- m.get("content", "")
53
- for m in row["chosen"]["messages"]
54
- if m["role"] == "assistant"
55
- )
56
- has_rejected = any(
57
- m.get("content", "")
58
- for m in row["rejected"]["messages"]
59
- if m["role"] == "assistant"
60
- )
61
- if has_chosen and has_rejected:
62
- self.ds.append(row)
63
- random.Random(seed).shuffle(self.ds)
64
- self.ds = self.ds[: int(len(self.ds) * frac)]
65
-
66
- def __len__(self):
67
- return len(self.ds)
68
-
69
- def __getitem__(self, idx: int):
70
- row = self.ds[idx]
71
- chosen = [{"role": "system", "content": SYSTEM_PROMPT}] + row["chosen"]["messages"]
72
- rejected = [{"role": "system", "content": SYSTEM_PROMPT}] + row["rejected"]["messages"]
73
- return {"messages": chosen}, {"messages": rejected}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/mixture.py DELETED
@@ -1,42 +0,0 @@
1
- """Deterministic interleaving of multiple sub-datasets."""
2
-
3
- import random
4
-
5
-
6
- class TaskMixture:
7
- def __init__(self, tasks, seed: int, **kwargs):
8
- super().__init__(**kwargs)
9
- self.tasks = tasks
10
- self.lengths = [len(task) for task in self.tasks]
11
- self.num_conversations = sum(self.lengths)
12
- self.index_map = []
13
- for task_idx, task_length in enumerate(self.lengths):
14
- for local_idx in range(task_length):
15
- self.index_map.append((task_idx, local_idx))
16
- random.Random(seed).shuffle(self.index_map)
17
-
18
- def __len__(self):
19
- return self.num_conversations
20
-
21
- def __getitem__(self, index):
22
- assert 0 <= index < self.num_conversations
23
- task_idx, local_idx = self.index_map[index]
24
- return self.tasks[task_idx][local_idx]
25
-
26
-
27
- class TaskSequence:
28
- def __init__(self, tasks, **kwargs):
29
- super().__init__(**kwargs)
30
- self.tasks = tasks
31
- self.lengths = [len(task) for task in self.tasks]
32
- self.num_conversations = sum(self.lengths)
33
-
34
- def __len__(self):
35
- return self.num_conversations
36
-
37
- def __getitem__(self, index):
38
- assert 0 <= index < self.num_conversations
39
- for task_idx, task_length in enumerate(self.lengths):
40
- if index < task_length:
41
- return self.tasks[task_idx][index]
42
- index -= task_length
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/pcap_data.py DELETED
@@ -1,224 +0,0 @@
1
- """Pcap-corpus pretrain stream.
2
-
3
- Materializes a domain-aligned third pretrain stream alongside web (climbmix /
4
- fineweb-edu) and code (the-stack-v2-dedup). The raw input is a JSONL file
5
- produced upstream by `capbench_agent/corpus/scripts/prepare_cpt_data.py`
6
- (57k+ docs across 11 knowledge layers: protocols, philosophy, mathematics,
7
- os_systems, forensics, network_traffic, reasoning, ndes_native, ...).
8
-
9
- This module converts the JSONL into parquet shards stored under
10
- `$NANOAI_BASE_DIR/code_pretrain_data/pcap-corpus/` so the existing
11
- `parquets_iter_batched` API (which expects parquet w/ a `text` column) can
12
- drive it identically to fineweb-edu / the-stack-v2-dedup.
13
-
14
- Run once before pretraining:
15
-
16
- python -m nanoai.agentic.data.pcap_data prepare \\
17
- --src /home/vader/capbench_agent/corpus/output/pretrain.jsonl \\
18
- --rows-per-shard 5000
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import argparse
24
- import json
25
- import time
26
- from pathlib import Path
27
-
28
- import pyarrow as pa
29
- import pyarrow.parquet as pq
30
-
31
- from nanoai.common import print0
32
- from nanoai.agentic.data.pretrain_data import DATA_DIR
33
-
34
- DATASET_NAME = "pcap-corpus"
35
- _TMP_SUFFIX = ".parquet.tmp"
36
-
37
-
38
- def pcap_corpus_dir() -> Path:
39
- return DATA_DIR / DATASET_NAME
40
-
41
-
42
- def _stream_jsonl_text(src: Path):
43
- with src.open("r", encoding="utf-8") as f:
44
- for line in f:
45
- line = line.strip()
46
- if not line:
47
- continue
48
- obj = json.loads(line)
49
- text = obj.get("text")
50
- if isinstance(text, str) and text:
51
- yield text
52
-
53
-
54
- def _try_open_shard(path: Path) -> bool:
55
- """Return True iff `path` is a readable parquet with ≥1 row group."""
56
- try:
57
- pf = pq.ParquetFile(path)
58
- return pf.num_row_groups > 0
59
- except Exception:
60
- return False
61
-
62
-
63
- def _validate_existing_corpus(out_dir: Path) -> tuple[bool, list[Path], str]:
64
- """Inspect out_dir to decide whether it holds a complete prepared corpus.
65
-
66
- Returns (is_valid, parquet_list, reason). A corpus is valid iff:
67
- - ≥2 parquet shards (so train/val split is non-empty), AND
68
- - every parquet is openable and has ≥1 row_group, AND
69
- - no leftover `*.parquet.tmp` files (signal of prior interrupted write).
70
- """
71
- parquets = sorted(p for p in out_dir.iterdir() if p.suffix == ".parquet")
72
- tmps = [p for p in out_dir.iterdir() if p.name.endswith(_TMP_SUFFIX)]
73
- if tmps:
74
- return False, parquets, f"{len(tmps)} stale {_TMP_SUFFIX} file(s) (prior crash?)"
75
- if len(parquets) < 2:
76
- return False, parquets, f"only {len(parquets)} shard(s); need ≥2"
77
- for p in parquets:
78
- if not _try_open_shard(p):
79
- return False, parquets, f"shard {p.name} unreadable or has 0 row_groups"
80
- return True, parquets, "ok"
81
-
82
-
83
- def _quarantine_stale(out_dir: Path, reason_slug: str) -> Path | None:
84
- """Move any *.parquet / *.parquet.tmp files aside before rebuilding.
85
-
86
- Uses an `.attic_<ts>_<reason>/` sub-dir so the operator can post-mortem
87
- what was wrong without us silently `rm -rf`-ing it.
88
- """
89
- movable = [
90
- p for p in out_dir.iterdir()
91
- if p.suffix == ".parquet" or p.name.endswith(_TMP_SUFFIX)
92
- ]
93
- if not movable:
94
- return None
95
- attic = out_dir / f".attic_{int(time.time())}_{reason_slug}"
96
- attic.mkdir()
97
- for entry in movable:
98
- entry.rename(attic / entry.name)
99
- print0(
100
- f"[pcap_data] quarantined {len(movable)} stale file(s) to {attic.name}/"
101
- f" (post-mortem; safe to delete once unblocked)"
102
- )
103
- return attic
104
-
105
-
106
- def _reason_slug(reason: str) -> str:
107
- return reason.split(";")[0].strip().replace(" ", "_")[:32] or "invalid"
108
-
109
-
110
- def prepare_from_jsonl(src: Path, rows_per_shard: int = 5000):
111
- """One-shot: JSONL -> parquet shards under pcap_corpus_dir().
112
-
113
- Last shard becomes the val split (mirrors fineweb-edu convention).
114
-
115
- Restart-safe:
116
- - if the output dir already holds a *valid* corpus (≥2 readable shards,
117
- no leftover .tmp), this is a no-op.
118
- - if the corpus is incomplete (single shard / unreadable parquet /
119
- stale .tmp), the stale files are quarantined into `.attic_<ts>_*/`
120
- and a full rebuild runs.
121
- - each new shard is written as `<name>.parquet.tmp` then renamed to
122
- `<name>.parquet` (atomic on POSIX), so a mid-shard crash leaves a
123
- clearly-named .tmp that this function will sweep on the next call.
124
- """
125
- out_dir = pcap_corpus_dir()
126
- out_dir.mkdir(parents=True, exist_ok=True)
127
-
128
- is_valid, existing, reason = _validate_existing_corpus(out_dir)
129
- if is_valid:
130
- print0(
131
- f"[pcap_data] {len(existing)} valid shards already in {out_dir}; "
132
- f"skipping prepare."
133
- )
134
- return existing
135
- # Either nothing is there, or what's there is incomplete/corrupt.
136
- if existing or any(p.name.endswith(_TMP_SUFFIX) for p in out_dir.iterdir()):
137
- print0(
138
- f"[pcap_data] existing corpus at {out_dir} is INVALID ({reason}); "
139
- f"quarantining and rebuilding from scratch."
140
- )
141
- _quarantine_stale(out_dir, _reason_slug(reason))
142
-
143
- assert src.exists(), f"Source JSONL not found: {src}"
144
- print0(f"[pcap_data] reading {src} → {out_dir} ({rows_per_shard} rows/shard)")
145
-
146
- shard_idx = 0
147
- buf: list[str] = []
148
- written: list[Path] = []
149
- total_docs = 0
150
- total_chars = 0
151
-
152
- def flush():
153
- nonlocal shard_idx, buf
154
- if not buf:
155
- return
156
- shard_path = out_dir / f"shard_{shard_idx:05d}.parquet"
157
- tmp_path = shard_path.with_name(shard_path.name + ".tmp")
158
- shard_chars = sum(len(t) for t in buf)
159
- table = pa.table({"text": buf})
160
- pq.write_table(table, tmp_path, compression="snappy")
161
- tmp_path.rename(shard_path) # atomic on POSIX
162
- written.append(shard_path)
163
- size_mb = shard_path.stat().st_size / 1e6
164
- print0(
165
- f"[pcap_data] wrote {shard_path.name} "
166
- f"({len(buf)} docs, {shard_chars/1e6:.2f}M chars, {size_mb:.2f} MB on disk)"
167
- )
168
- shard_idx += 1
169
- buf = []
170
-
171
- for text in _stream_jsonl_text(src):
172
- buf.append(text)
173
- total_docs += 1
174
- total_chars += len(text)
175
- if len(buf) >= rows_per_shard:
176
- flush()
177
- flush()
178
-
179
- assert len(written) >= 2, (
180
- f"Only {len(written)} shard(s) written; need ≥2 so train/val split is non-empty. "
181
- f"Lower --rows-per-shard or supply a larger JSONL."
182
- )
183
- # Rough token-budget estimate using 4 chars/token (English) — matches the
184
- # ballpark we see in nanoai's tokenizer for educational + code mix.
185
- est_tokens = total_chars / 4
186
- print0(
187
- f"[pcap_data] done: {len(written)} shards (last is val), "
188
- f"{total_docs:,} docs, {total_chars/1e6:.1f}M chars, "
189
- f"≈{est_tokens/1e6:.1f}M tokens (4 chars/tok estimate)"
190
- )
191
- return written
192
-
193
-
194
- def list_pcap_parquet_files() -> list[Path]:
195
- d = pcap_corpus_dir()
196
- if not d.exists():
197
- return []
198
- return sorted([d / f for f in d.iterdir() if f.suffix == ".parquet"])
199
-
200
-
201
- def main():
202
- parser = argparse.ArgumentParser(description="Prepare pcap-corpus parquet shards.")
203
- sub = parser.add_subparsers(dest="cmd", required=True)
204
- prep = sub.add_parser("prepare", help="convert a JSONL to parquet shards")
205
- prep.add_argument("--src", type=Path, required=True, help="path to pretrain.jsonl")
206
- prep.add_argument("--rows-per-shard", type=int, default=5000)
207
- sub.add_parser("list", help="list current pcap-corpus shards")
208
-
209
- args = parser.parse_args()
210
- if args.cmd == "prepare":
211
- prepare_from_jsonl(args.src, rows_per_shard=args.rows_per_shard)
212
- elif args.cmd == "list":
213
- shards = list_pcap_parquet_files()
214
- if not shards:
215
- print(f"No shards in {pcap_corpus_dir()}")
216
- return
217
- total_bytes = sum(s.stat().st_size for s in shards)
218
- for s in shards:
219
- print(f" {s.name} ({s.stat().st_size / 1e6:.2f} MB)")
220
- print(f"total: {len(shards)} shards, {total_bytes / 1e6:.1f} MB")
221
-
222
-
223
- if __name__ == "__main__":
224
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/pretrain_data.py DELETED
@@ -1,115 +0,0 @@
1
- """Parquet-shard downloader and iterator for the two pretrain datasets:
2
- - karpathy/fineweb-edu-100b-shuffle (web text)
3
- - smohammadi/the-stack-v2-python-shuffle (Python code)
4
-
5
- Honors HF_ENDPOINT so a mirror (e.g. hf-mirror.com) can be substituted.
6
- Run as a CLI: `python -m nanoai.agentic.data.pretrain_data -d fineweb-edu -n 4`
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import os
13
- import time
14
- from functools import partial
15
- from multiprocessing import Pool
16
- from pathlib import Path
17
-
18
- import pyarrow.parquet as pq
19
- import requests
20
-
21
- from nanoai.common import get_base_dir, get_dist_info, print0
22
-
23
- DATA_DIR = Path(get_base_dir()) / "code_pretrain_data"
24
- DATA_DIR.mkdir(parents=True, exist_ok=True)
25
-
26
-
27
- def index_to_filename(index: int) -> str:
28
- return f"shard_{index:05d}.parquet"
29
-
30
-
31
- def list_parquet_files(data_dir: Path):
32
- return sorted([data_dir / f for f in data_dir.iterdir() if f.suffix == ".parquet"])
33
-
34
-
35
- def parquets_iter_batched(dataset: str, split: str, start: int = 0, step: int = 1):
36
- """Yield row-group-sized batches of `text` strings.
37
-
38
- `split="val"` returns only the last shard; `split="train"` returns the rest.
39
- `start`/`step` slice row-groups for DDP rank/world-size sharding.
40
- """
41
- assert dataset in ("fineweb-edu", "the-stack-v2-dedup", "network-domain", "pcap-corpus")
42
- assert split in ("train", "val")
43
- data_dir = DATA_DIR / dataset
44
- parquet_paths = list_parquet_files(data_dir)
45
- if not parquet_paths:
46
- raise FileNotFoundError(
47
- f"No parquet shards found in {data_dir}. "
48
- f"Run: python -m nanoai.agentic.data.pretrain_data -d {dataset} -n N"
49
- )
50
- parquet_paths = parquet_paths[:-1] if split == "train" else parquet_paths[-1:]
51
- for filepath in parquet_paths:
52
- pf = pq.ParquetFile(filepath)
53
- for rg_idx in range(start, pf.num_row_groups, step):
54
- rg = pf.read_row_group(rg_idx)
55
- yield rg.column("text").to_pylist()
56
-
57
-
58
- def download_single_file(index: int, base_url: str, data_dir: Path) -> bool:
59
- filename = index_to_filename(index)
60
- filepath = data_dir / filename
61
- if filepath.exists():
62
- return True
63
-
64
- url = f"{base_url}/{filename}"
65
- print0(f"Downloading {filename}...")
66
-
67
- for attempt in range(1, 6):
68
- try:
69
- response = requests.get(url, stream=True, timeout=30)
70
- response.raise_for_status()
71
- temp_path = filepath.with_name(filepath.name + ".tmp")
72
- with open(temp_path, "wb") as f:
73
- for chunk in response.iter_content(chunk_size=1024 * 1024):
74
- if chunk:
75
- f.write(chunk)
76
- temp_path.rename(filepath)
77
- print0(f"Successfully downloaded {filename}")
78
- return True
79
- except (requests.RequestException, IOError) as e:
80
- print0(f"Attempt {attempt}/5 failed for {filename}: {e}")
81
- filepath.unlink(missing_ok=True)
82
- filepath.with_name(filepath.name + ".tmp").unlink(missing_ok=True)
83
- if attempt < 5:
84
- time.sleep(2 ** attempt)
85
- print0(f"Failed to download {filename}")
86
- return False
87
-
88
-
89
- def main():
90
- parser = argparse.ArgumentParser(description="Download pretraining dataset shards")
91
- parser.add_argument("-d", "--dataset", choices=["fineweb-edu", "the-stack-v2-dedup"], required=True)
92
- parser.add_argument("-n", "--num-files", type=int, default=-1)
93
- parser.add_argument("-w", "--num-workers", type=int, default=4)
94
- args = parser.parse_args()
95
-
96
- hf_endpoint = os.environ.get("HF_ENDPOINT", "https://huggingface.co").rstrip("/")
97
- if args.dataset == "fineweb-edu":
98
- base_url = f"{hf_endpoint}/datasets/karpathy/fineweb-edu-100b-shuffle/resolve/main"
99
- max_shard = 1822
100
- else:
101
- base_url = f"{hf_endpoint}/datasets/smohammadi/the-stack-v2-python-shuffle/resolve/main"
102
- max_shard = 79
103
-
104
- data_dir = DATA_DIR / args.dataset
105
- data_dir.mkdir(parents=True, exist_ok=True)
106
- num = max_shard + 1 if args.num_files == -1 else min(args.num_files, max_shard + 1)
107
- ids = list(range(num))
108
- print0(f"Downloading {len(ids)} shards from {base_url} → {data_dir}")
109
- with Pool(processes=args.num_workers) as pool:
110
- results = pool.map(partial(download_single_file, base_url=base_url, data_dir=data_dir), ids)
111
- print0(f"Done: {sum(results)}/{len(ids)} shards")
112
-
113
-
114
- if __name__ == "__main__":
115
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/pretrain_mix_1b.py DELETED
@@ -1,466 +0,0 @@
1
- """Self-contained 5-stream pretrain mixer for the 1B-challenge d24 base.
2
-
3
- Mixes five document streams with a per-document categorical (Bernoulli) draw,
4
- then BOS-aligned best-fit packs into (B, T) rows — identical packing to
5
- ``nanoai.agentic.code_dataloader`` (whose ``_doc_batches_from`` / ``_split_paths``
6
- helpers are reused), but kept in a *separate* module so the validated agentic /
7
- d6 / d12 code-mixer is untouched (zero regression risk).
8
-
9
- Streams (all expose a ``text`` column; verified on wuneng 2026-06-08):
10
-
11
- | name | source dir (under base_dir) | files | col |
12
- |---------|--------------------------------------------------|------:|------|
13
- | web | base_data_climbmix/ | 1501 | text |
14
- | owm | code_pretrain_data/open-web-math/data/ | 114 | text |
15
- | code | code_pretrain_data/the-stack-v2-dedup/ | 510 | text |
16
- | books | code_pretrain_data/_ebook_orig/ | 20 | text |
17
- | chinese | chinese_raw/{fineweb_edu_zh,wikipedia_zh}/** | 29 | text |
18
- | finemath | code_pretrain_data/finemath/finemath-3plus/ | — | text |
19
- | mathtb | code_pretrain_data/nemotron_specialized/Nemotron-Pretraining-Math-Textbooks/ | — | text |
20
- | swecpt | code_pretrain_data/swe_mid/cpt/ | — | text |
21
- | commitdiff | code_pretrain_data/commitpackft/ | — | text |
22
- | codetests | code_pretrain_data/code_tests/ | — | text |
23
- | nvcode | code_pretrain_data/nemotron_code/ | — | text |
24
- | swe1b | code_pretrain_data/swe1b_midcpt/ (REAL SWE-Gym) | — | text |
25
-
26
- ``swecpt`` is SYNTHETIC (mutation-injected) and DEPRECATED; ``swe1b`` is its REAL
27
- clean replacement — decontam'd SWE-Gym issue+gold-patch docs from
28
- ``experiments/swe1b`` (serialize_midtrain_cpt -> jsonl_to_parquet). Kept as
29
- separate streams so real and synthetic SWE data never co-mingle.
30
-
31
- The SWE-mid streams (swecpt/commitdiff/codetests/nvcode) are produced by
32
- ``experiments/challenge1b/tools/build_{commitdiff,code_tests,nvcode}_stream.py``
33
- and ``gen_swe_mut`` (recipe: ``experiments/challenge1b/docs/d6_swe_mid_design.md``).
34
-
35
- ``owm/code/books/chinese`` ratios are explicit; ``web`` takes the remainder.
36
- The ratios passed here are PER-DOCUMENT draw probabilities; to hit a target
37
- *token* share, calibrate them against measured mean-tokens/doc per stream
38
- (see ``experiments/challenge1b/tools/measure_mix.py``). The recipe of record is
39
- ``experiments/challenge1b/docs/pretrain_recipe.md``.
40
-
41
- Resume state is a stub (single epoch counter), matching the code-mixer — fine
42
- for a single contiguous ~15h run; true multi-stream resume is not implemented.
43
- """
44
-
45
- from __future__ import annotations
46
-
47
- import random
48
- from pathlib import Path
49
-
50
- import pyarrow.parquet as pq
51
- import torch
52
-
53
- from nanoai.common import get_base_dir, get_dist_info, print0
54
- from nanoai.dataset import list_parquet_files as list_web_parquet_files
55
- from nanoai.agentic.data.pretrain_data import DATA_DIR as _CODE_BASE_DATA_DIR
56
- from nanoai.agentic.code_dataloader import _split_paths
57
-
58
-
59
- def _doc_batches_robust(paths, ddp_rank, ddp_world_size, tokenizer_batch_size, split,
60
- seed: int = 0, shuffle_buffer: int = 10000, text_col: str = "text"):
61
- """Infinite per-rank list[str] doc-batch iterator: starvation-safe + shuffled.
62
-
63
- Two fixes over the upstream code-mixer's ``_doc_batches_from``:
64
-
65
- 1. STARVATION. Upstream stripes by ROW-GROUP (``range(rank, num_rg, world)``),
66
- which starves any rank >= a shard's row-group count. Several streams here
67
- ship single-row-group shards (``_ebook_orig`` books: 1 rg/shard), so ranks
68
- 1..3 got ZERO books docs and blocked forever -> DDP deadlock at eval@step0.
69
- Fix: train stripes by FILE (every stream has >=19 train shards >= world);
70
- val reads the FULL val shard on every rank (single-file shards; eval just
71
- averages bpb so cross-rank overlap is harmless).
72
-
73
- 2. SHUFFLE. We read shards file-sequentially, and not every stream is
74
- pre-shuffled within-shard (web/code/owm shards are; chinese isn't
75
- guaranteed). The bestfit packer selects by LENGTH, not randomly, so it is
76
- NOT a shuffle. Train docs therefore pass through a reservoir shuffle buffer
77
- to decorrelate ordering; val is read deterministically (reproducible eval).
78
- """
79
- if split == "val":
80
- my_paths = list(paths)
81
- else:
82
- my_paths = paths[ddp_rank::ddp_world_size] or list(paths)
83
-
84
- def _docs():
85
- while True:
86
- for filepath in my_paths:
87
- pf = pq.ParquetFile(filepath)
88
- for rg_idx in range(pf.num_row_groups):
89
- for d in pf.read_row_group(rg_idx, columns=[text_col]).column(text_col).to_pylist():
90
- if d:
91
- yield d
92
-
93
- src = _docs()
94
- rng = random.Random(seed + ddp_rank + 1)
95
- buf: list[str] = []
96
- batch: list[str] = []
97
- while True:
98
- if split == "train":
99
- while len(buf) < shuffle_buffer: # reservoir: fill then emit random picks
100
- buf.append(next(src))
101
- j = rng.randrange(len(buf))
102
- buf[j], buf[-1] = buf[-1], buf[j] # swap-to-end + pop = O(1) random eviction
103
- batch.append(buf.pop())
104
- else:
105
- batch.append(next(src)) # val: deterministic order
106
- if len(batch) >= tokenizer_batch_size:
107
- yield batch
108
- batch = []
109
-
110
-
111
- # --------------------------------------------------------------- stream paths
112
- _CODE_DATASET_NAME = "the-stack-v2-dedup"
113
- _OWM_SUBPATH = ("open-web-math", "data") # parquets live under open-web-math/data/
114
- _BOOKS_DATASET_NAME = "_ebook_orig" # intact STEM-textbook parquets (ebook-md was clobbered)
115
- _CHINESE_DIRNAME = "chinese_raw"
116
- _CHINESE_SUBDIRS = ("fineweb_edu_zh", "wikipedia_zh")
117
-
118
-
119
- def list_code_parquet_files() -> list[Path]:
120
- # the-stack-v2-dedup/ also holds 67 ``netis_part_*`` shards (a network-company
121
- # repo dump: shell/go/c tooling code, off the general-code distribution). Drop
122
- # them so the ``code`` stream is purely the-stack / starcoder (sc2_*/sc_*/shard_*).
123
- d = _CODE_BASE_DATA_DIR / _CODE_DATASET_NAME
124
- if not d.exists():
125
- return []
126
- return sorted(p for p in d.glob("*.parquet") if not p.name.startswith("netis"))
127
-
128
-
129
- def list_owm_parquet_files() -> list[Path]:
130
- d = _CODE_BASE_DATA_DIR.joinpath(*_OWM_SUBPATH)
131
- return sorted(d.glob("*.parquet")) if d.exists() else []
132
-
133
-
134
- def list_books_parquet_files() -> list[Path]:
135
- d = _CODE_BASE_DATA_DIR / _BOOKS_DATASET_NAME
136
- return sorted(d.glob("*.parquet")) if d.exists() else []
137
-
138
-
139
- def list_finemath_parquet_files() -> list[Path]:
140
- # HuggingFaceTB/finemath finemath-3plus (~34B tok, L2-tier classifier-selected)
141
- d = _CODE_BASE_DATA_DIR / "finemath" / "finemath-3plus"
142
- return sorted(d.glob("*.parquet")) if d.exists() else []
143
-
144
-
145
- def list_mathtb_parquet_files() -> list[Path]:
146
- # Nemotron-Pretraining-Math-Textbooks (L3-tier synthetic textbooks, from wukong)
147
- d = _CODE_BASE_DATA_DIR / "nemotron_specialized" / "Nemotron-Pretraining-Math-Textbooks"
148
- return sorted(d.glob("*.parquet")) if d.exists() else []
149
-
150
-
151
- def list_swecpt_parquet_files() -> list[Path]:
152
- # gen_swe_mut cpt-view text docs (# buggy / # test / # traceback / # fix)
153
- d = _CODE_BASE_DATA_DIR / "swe_mid" / "cpt"
154
- return sorted(d.glob("*.parquet")) if d.exists() else []
155
-
156
-
157
- def list_commitdiff_parquet_files() -> list[Path]:
158
- # CommitPackFT python slice rendered Edit-tool-isomorphic (build_commitdiff_stream.py)
159
- d = _CODE_BASE_DATA_DIR / "commitpackft"
160
- return sorted(d.glob("*.parquet")) if d.exists() else []
161
-
162
-
163
- def list_codetests_parquet_files() -> list[Path]:
164
- # the-stack docs that carry their own tests (build_code_tests_stream.py)
165
- d = _CODE_BASE_DATA_DIR / "code_tests"
166
- return sorted(d.glob("*.parquet")) if d.exists() else []
167
-
168
-
169
- def list_nvcode_parquet_files() -> list[Path]:
170
- # Nemotron scientific-coding + Llama-Nemotron SFT code (build_nvcode_stream.py)
171
- d = _CODE_BASE_DATA_DIR / "nemotron_code"
172
- return sorted(d.glob("*.parquet")) if d.exists() else []
173
-
174
-
175
- def list_swe1b_parquet_files() -> list[Path]:
176
- # REAL SWE-Gym mid corpus (decontam vs Verified): issue + gold-patch bug->fix
177
- # docs from experiments/swe1b (serialize_midtrain_cpt -> jsonl_to_parquet).
178
- # This is the clean real-data replacement for the synthetic ``swecpt`` stream;
179
- # the two are kept SEPARATE so real and synthetic SWE data never mix.
180
- d = _CODE_BASE_DATA_DIR / "swe1b_midcpt"
181
- return sorted(d.glob("*.parquet")) if d.exists() else []
182
-
183
-
184
- def list_chinese_parquet_files() -> list[Path]:
185
- base = Path(get_base_dir()) / _CHINESE_DIRNAME
186
- out: list[Path] = []
187
- if base.exists():
188
- for sub in _CHINESE_SUBDIRS:
189
- d = base / sub
190
- if d.exists():
191
- out.extend(sorted(d.rglob("*.parquet"))) # nested IndustryCorpus/ etc.
192
- return out
193
-
194
-
195
- def list_ebook_nf_parquet_files() -> list[Path]:
196
- """Re-made LONG non-fiction books (build_ebook_long_stream --split-type): full
197
- books as single long docs (p50 ~131K tok), NOT the chunked ``_ebook_orig``. The
198
- genuine long-context natural-language source for the staged decay."""
199
- d = _CODE_BASE_DATA_DIR / "ebook_long" / "non_fiction"
200
- return sorted(d.glob("*.parquet")) if d.exists() else []
201
-
202
-
203
- def list_ebook_other_parquet_files() -> list[Path]:
204
- """Re-made LONG other (literature/general) books — long-doc companion to ebook_nf."""
205
- d = _CODE_BASE_DATA_DIR / "ebook_long" / "other"
206
- return sorted(d.glob("*.parquet")) if d.exists() else []
207
-
208
-
209
- def list_ultrafineweb_parquet_files() -> list[Path]:
210
- """OpenBMB Ultra-FineWeb (en) — classifier-filtered web (MiniCPM UltraData L2).
211
- The A5 ablation swaps the legacy climbmix ``web`` stream for this higher-quality
212
- filtered web. Short docs (web pages), so this is a web-QUALITY lever, not a
213
- long-context one."""
214
- d = _CODE_BASE_DATA_DIR / "ultra_fineweb" / "data" / "ultrafineweb_en"
215
- return sorted(d.glob("*.parquet")) if d.exists() else []
216
-
217
-
218
- def list_retrieval_parquet_files() -> list[Path]:
219
- """Retrieval-shaping synthetic corpus (build_retrieval_stream): long real-prose docs with
220
- K facts spread across the body + a shuffled recall index at the end, so predicting the
221
- recalled values forces precise long-range retrieval. Long-context capability lever (push
222
- reliable needle retrieval past ~64K toward the full 128K)."""
223
- d = _CODE_BASE_DATA_DIR / "retrieval_shaping"
224
- return sorted(d.glob("*.parquet")) if d.exists() else []
225
-
226
-
227
- def list_multilingual_parquet_files() -> list[Path]:
228
- """The 18 高人口/高 GDP 亚非拉 languages (build_multilingual_stream): per-language shards under
229
- ``multilingual/<lang>/`` from FineWeb-2 / CulturaX / MADLAD-400 / Wikipedia. The general-purpose
230
- multilingual lever — needs the new multilingual tokenizer to compress non-Latin scripts (see
231
- gate_tok_compression / gate_multilingual)."""
232
- d = _CODE_BASE_DATA_DIR / "multilingual"
233
- return sorted(d.rglob("*.parquet")) if d.exists() else [] # nested per-language subdirs
234
-
235
-
236
- # Stream name -> lister. ``web`` is handled separately (legacy-fallback aware).
237
- NON_WEB_LISTERS = {
238
- "owm": list_owm_parquet_files,
239
- "code": list_code_parquet_files,
240
- "books": list_books_parquet_files,
241
- "chinese": list_chinese_parquet_files,
242
- "finemath": list_finemath_parquet_files,
243
- "mathtb": list_mathtb_parquet_files,
244
- "swecpt": list_swecpt_parquet_files,
245
- "commitdiff": list_commitdiff_parquet_files,
246
- "codetests": list_codetests_parquet_files,
247
- "nvcode": list_nvcode_parquet_files,
248
- "swe1b": list_swe1b_parquet_files,
249
- # re-made LONG books (long-context decay source); chunked ``books`` kept for the
250
- # A0 baseline comparison.
251
- "ebook_nf": list_ebook_nf_parquet_files,
252
- "ebook_other": list_ebook_other_parquet_files,
253
- # filtered-web (MiniCPM UltraData L2); A5 swaps legacy ``web`` -> this.
254
- "ultrafineweb": list_ultrafineweb_parquet_files,
255
- # retrieval-shaping long docs (push reliable retrieval past ~64K toward 128K).
256
- "retrieval": list_retrieval_parquet_files,
257
- # the 18 亚非拉 languages (general-purpose multilingual lever).
258
- "multilingual": list_multilingual_parquet_files,
259
- }
260
- # Additive decision-order slices of [0,1): APPEND-ONLY — reordering would change
261
- # the realized ratios of every existing recipe (web takes the remainder).
262
- STREAM_ORDER = ("owm", "code", "books", "chinese", "finemath", "mathtb",
263
- "swecpt", "commitdiff", "codetests", "nvcode", "swe1b",
264
- "ebook_nf", "ebook_other", "ultrafineweb", "retrieval",
265
- "multilingual")
266
- # Per-stream parquet text column (default "text"). Ultra-FineWeb ships "content".
267
- _STREAM_TEXT_COL = {"ultrafineweb": "content"}
268
-
269
-
270
- def _resolve_paths(split: str, warn: bool):
271
- """Return {name: [train/val paths]} for web + the four non-web streams."""
272
- web = [Path(p) for p in _split_paths(list_web_parquet_files(warn_on_legacy=warn), split)]
273
- assert web, "No web (climbmix) parquet shards found; run nanoai.dataset first."
274
- paths = {"web": web}
275
- for name, lister in NON_WEB_LISTERS.items():
276
- paths[name] = _split_paths(lister(), split)
277
- return paths
278
-
279
-
280
- # --------------------------------------------------------------- doc batches
281
- def _mixed_document_batches_1b(
282
- split: str,
283
- ratios: dict[str, float],
284
- tokenizer_batch_size: int,
285
- seed: int = 0,
286
- ):
287
- """Infinite stream of list[str] doc batches drawn from the 5-way mixture.
288
-
289
- ``ratios`` holds per-document draw probabilities for the non-web streams
290
- (owm/code/books/chinese); web gets the remainder. Decision order matches
291
- ``STREAM_ORDER`` then web — additive slices of [0, 1).
292
- """
293
- for k in ratios:
294
- assert k in NON_WEB_LISTERS, f"unknown stream ratio {k!r}"
295
- assert 0.0 <= ratios[k] <= 1.0, f"bad ratio {k}={ratios[k]}"
296
- non_web_total = sum(ratios.get(n, 0.0) for n in STREAM_ORDER)
297
- assert non_web_total <= 1.0 + 1e-9, f"non-web ratios sum {non_web_total} > 1"
298
-
299
- ddp, ddp_rank, _local, ddp_world_size = get_dist_info()
300
- warn = (ddp_rank == 0) and (split == "train")
301
- paths = _resolve_paths(split, warn)
302
-
303
- for name in STREAM_ORDER:
304
- if ratios.get(name, 0.0) > 0 and not paths[name]:
305
- raise FileNotFoundError(
306
- f"{name}_ratio>0 but no parquet found for stream '{name}'. "
307
- f"Expected under base_dir; see pretrain_mix_1b stream table."
308
- )
309
-
310
- iters = {
311
- "web": _doc_batches_robust(paths["web"], ddp_rank, ddp_world_size, tokenizer_batch_size, split)
312
- }
313
- for name in STREAM_ORDER:
314
- iters[name] = (
315
- _doc_batches_robust(paths[name], ddp_rank, ddp_world_size, tokenizer_batch_size, split,
316
- text_col=_STREAM_TEXT_COL.get(name, "text"))
317
- if paths[name] else None
318
- )
319
-
320
- if ddp_rank == 0:
321
- web_ratio = max(0.0, 1.0 - non_web_total)
322
- shard_str = " ".join(f"{n}={len(paths[n])}" for n in ("web", *STREAM_ORDER))
323
- ratio_str = " ".join(
324
- f"{n}={ratios.get(n, 0.0):.3f}" for n in STREAM_ORDER
325
- )
326
- print0(f"[mix-1b] split={split} web={web_ratio:.3f} {ratio_str} | shards {shard_str}")
327
-
328
- rng = random.Random(seed + ddp_rank)
329
- counts = {n: 0 for n in ("web", *STREAM_ORDER)}
330
- log_every = 10000
331
- while True:
332
- r = rng.random()
333
- acc = 0.0
334
- chosen = "web"
335
- for name in STREAM_ORDER:
336
- acc += ratios.get(name, 0.0)
337
- if iters[name] is not None and r < acc:
338
- chosen = name
339
- break
340
- counts[chosen] += 1
341
- yield next(iters[chosen])
342
- total = sum(counts.values())
343
- if ddp_rank == 0 and total % log_every == 0:
344
- frac = " ".join(f"{n}={counts[n]/total:.3f}" for n in ("web", *STREAM_ORDER))
345
- print0(f"[mix-1b] draws={total} {frac}")
346
-
347
-
348
- # --------------------------------------------------------------- bestfit packer
349
- def tokenizing_mixed_1b(
350
- tokenizer,
351
- B: int,
352
- T: int,
353
- split: str,
354
- ratios: dict[str, float],
355
- tokenizer_threads: int = 4,
356
- tokenizer_batch_size: int = 128,
357
- device: str = "cuda",
358
- buffer_size: int = 1000,
359
- seed: int = 0,
360
- ):
361
- """BOS-aligned best-fit packing over the 5-way mixture. Yields (inputs,
362
- targets) tensors of shape (B, T). Drop-in for nanoai's
363
- ``tokenizing_distributed_data_loader_bos_bestfit``.
364
- """
365
- assert split in ("train", "val")
366
- row_capacity = T + 1
367
- bos_token = tokenizer.get_bos_token_id()
368
-
369
- batches = _mixed_document_batches_1b(split, ratios, tokenizer_batch_size, seed=seed)
370
- doc_buffer: list[list[int]] = []
371
-
372
- def refill_buffer():
373
- doc_batch = next(batches)
374
- token_lists = tokenizer.encode(doc_batch, prepend=bos_token, num_threads=tokenizer_threads)
375
- for tokens in token_lists:
376
- doc_buffer.append(tokens)
377
-
378
- use_cuda = device == "cuda"
379
- row_buffer = torch.empty((B, row_capacity), dtype=torch.long)
380
- cpu_buffer = torch.empty(2 * B * T, dtype=torch.long, pin_memory=use_cuda)
381
- gpu_buffer = torch.empty(2 * B * T, dtype=torch.long, device=device)
382
- cpu_inputs = cpu_buffer[: B * T].view(B, T)
383
- cpu_targets = cpu_buffer[B * T :].view(B, T)
384
- inputs = gpu_buffer[: B * T].view(B, T)
385
- targets = gpu_buffer[B * T :].view(B, T)
386
-
387
- while True:
388
- for row_idx in range(B):
389
- pos = 0
390
- while pos < row_capacity:
391
- while len(doc_buffer) < buffer_size:
392
- refill_buffer()
393
- remaining = row_capacity - pos
394
-
395
- best_idx = -1
396
- best_len = 0
397
- for i, doc in enumerate(doc_buffer):
398
- doc_len = len(doc)
399
- if doc_len <= remaining and doc_len > best_len:
400
- best_idx = i
401
- best_len = doc_len
402
-
403
- if best_idx >= 0:
404
- doc = doc_buffer.pop(best_idx)
405
- doc_len = len(doc)
406
- row_buffer[row_idx, pos : pos + doc_len] = torch.tensor(doc, dtype=torch.long)
407
- pos += doc_len
408
- else:
409
- shortest_idx = min(range(len(doc_buffer)), key=lambda i: len(doc_buffer[i]))
410
- doc = doc_buffer.pop(shortest_idx)
411
- row_buffer[row_idx, pos : pos + remaining] = torch.tensor(
412
- doc[:remaining], dtype=torch.long
413
- )
414
- pos += remaining
415
-
416
- cpu_inputs.copy_(row_buffer[:, :-1])
417
- cpu_targets.copy_(row_buffer[:, 1:])
418
- gpu_buffer.copy_(cpu_buffer, non_blocking=use_cuda)
419
- yield inputs, targets
420
-
421
-
422
- def tokenizing_mixed_1b_with_state(
423
- tokenizer,
424
- B: int,
425
- T: int,
426
- split: str,
427
- ratios: dict[str, float],
428
- tokenizer_threads: int = 4,
429
- tokenizer_batch_size: int = 128,
430
- device: str = "cuda",
431
- resume_state_dict=None,
432
- buffer_size: int = 1000,
433
- seed: int = 0,
434
- ):
435
- """Same as ``tokenizing_mixed_1b`` but yields a third stub ``state_dict`` to
436
- match ``tokenizing_distributed_data_loader_with_state_bos_bestfit``.
437
- Caller's ``resume_state_dict`` is ignored (multi-stream resume not impl.)."""
438
- base = tokenizing_mixed_1b(
439
- tokenizer, B, T, split, ratios,
440
- tokenizer_threads=tokenizer_threads,
441
- tokenizer_batch_size=tokenizer_batch_size,
442
- device=device, buffer_size=buffer_size, seed=seed,
443
- )
444
- stub_state = {"epoch": 1, "pq_idx": 0, "rg_idx": 0}
445
- for inputs, targets in base:
446
- yield inputs, targets, stub_state
447
-
448
-
449
- # --------------------------------------------------------------- tokenizer-train iterator
450
- def stream_text_iterator(ratios: dict[str, float], doc_cap: int, max_chars: int, seed: int = 0):
451
- """Yield individual doc strings drawn from the 5-way mixture (single rank),
452
- each truncated to ``doc_cap`` chars, stopping after ``max_chars`` total.
453
-
454
- Used by ``scripts/tok_train_1b.py`` so the BPE sees a char distribution that
455
- matches the pretrain mix (English / math / code / textbooks / Chinese).
456
- """
457
- batches = _mixed_document_batches_1b("train", ratios, tokenizer_batch_size=128, seed=seed)
458
- nchars = 0
459
- while nchars < max_chars:
460
- batch = next(batches)
461
- for doc in batch:
462
- doc = doc[:doc_cap]
463
- nchars += len(doc)
464
- yield doc
465
- if nchars >= max_chars:
466
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/routing_recovery.py DELETED
@@ -1,357 +0,0 @@
1
- """Synthetic tool-routing recovery dataset.
2
-
3
- Motivation (POC-8 mechanistic analysis):
4
- v9 (lc=10) regressed from v5 by 5.3pp because long_ctx data lacks tool calls,
5
- causing catastrophic forgetting of *which tool to use for which task type*.
6
- Failed cases (read_config, grep_torch, bash_grep_log) showed v9 emitting
7
- Edit-only with degenerate repetitive content where v5 emitted the correct
8
- Read/Grep/Bash chain.
9
-
10
- This dataset teaches the model the prompt-shape → first-tool mapping
11
- directly: 4 tool families × ~50 prompt templates × 4-5 surface variants =
12
- ~200 rows per tool, ~800 rows total. Each row is a 4-message conversation
13
- (user → assistant tool_call → synthetic tool_result → assistant answer).
14
-
15
- Format on disk: JSONL, one `{"messages": [...]}` per line, compatible with
16
- `render_agentic_conversation`'s tool-call branch (assistant turn carries
17
- both `content` and `tool_call: {name, args}`).
18
-
19
- CLI:
20
- python -m nanoai.agentic.data.routing_recovery \
21
- --output data/routing_recovery.jsonl --seed 42 --n-per-tool 200
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import argparse
27
- import json
28
- import random
29
- from dataclasses import dataclass
30
- from pathlib import Path
31
- from typing import Any
32
-
33
- from nanoai.agentic.data.common import SYSTEM_PROMPT
34
-
35
-
36
- # ---- File / pattern / path corpora (deterministic per-seed shuffling) -------
37
-
38
- _FILES_PY = [
39
- "main.py", "app.py", "lib.py", "utils.py", "config.py", "server.py",
40
- "client.py", "models.py", "schema.py", "data.py", "tools.py", "test.py",
41
- "parser.py", "router.py", "loader.py", "engine.py", "core.py", "common.py",
42
- "tasks.py", "errors.py",
43
- ]
44
- _FILES_DATA = [
45
- "config.toml", "settings.json", "data.csv", "users.json", "log.txt",
46
- "README.md", "Makefile", "requirements.txt", "pyproject.toml", ".env",
47
- "dataset.json", "errors.log", "access.log", "metrics.tsv", "manifest.yaml",
48
- ]
49
- _DIRS = ["src", "lib", "tests", "scripts", "data", "config", ".", "core", "app"]
50
- _PATTERNS = [
51
- "TODO", "FIXME", "import torch", "def __init__", "raise ValueError",
52
- "class .*Error", "logging.basicConfig", "yield from", "async def",
53
- "from typing import", "with pytest.raises", "@dataclass",
54
- "logger.info", "self\\.config", "return None",
55
- ]
56
- _BASH_TARGETS = [
57
- "list files", "count lines", "show disk usage", "show file size",
58
- "find files larger than 1mb", "show last 10 lines", "show first 5 lines",
59
- "show file modification time",
60
- ]
61
-
62
-
63
- # ---- Templates per tool family ---------------------------------------------
64
- #
65
- # Each template is a function (rng) -> {prompt, tool_name, tool_args,
66
- # observation, final_answer}.
67
-
68
- @dataclass
69
- class _Row:
70
- user_prompt: str
71
- pre_text: str # short assistant pre-text before tool_call
72
- tool_name: str
73
- tool_args: dict[str, str]
74
- tool_result: str # synthetic observation
75
- final_text: str # assistant's answer after seeing observation
76
-
77
-
78
- def _read_row(rng: random.Random) -> _Row:
79
- f = rng.choice(_FILES_PY + _FILES_DATA)
80
- template = rng.choice([
81
- ("what's in {f}?", "let me read it"),
82
- ("show me {f}", "i'll open it"),
83
- ("can you read {f}?", "reading"),
84
- ("open {f} and tell me what it does", "i'll read the file first"),
85
- ("what does {f} say?", "checking"),
86
- ("read {f} please", "ok"),
87
- ("display the contents of {f}", "ok"),
88
- ("look at {f}", "reading"),
89
- ("how is {f} structured?", "let me see"),
90
- ("give me the source of {f}", "fetching"),
91
- ])
92
- prompt = template[0].format(f=f)
93
- pre = template[1]
94
- if f.endswith(".py"):
95
- body = f"def main():\n print('hello from {f}')\n\nif __name__ == '__main__':\n main()"
96
- ans = f"{f} defines a `main()` that prints a hello banner; runs only when invoked directly."
97
- elif f == "config.toml":
98
- ans = "the version is 0.1.0; package name is `nanocode`."
99
- body = "[package]\nname = 'nanocode'\nversion = '0.1.0'\n"
100
- elif f == "README.md":
101
- body = "# Project\n\nA small library.\n\n## Install\n`pip install .`\n"
102
- ans = "small library titled 'Project' with a single install instruction."
103
- elif f.endswith(".json"):
104
- body = '{"version": "1.2", "users": [{"id": 1}, {"id": 2}]}'
105
- ans = "JSON config with version 1.2 and two users."
106
- elif f.endswith(".log"):
107
- body = "2026-05-07 INFO start\n2026-05-07 INFO ready\n"
108
- ans = "two INFO log lines, both from 2026-05-07."
109
- else:
110
- body = "# placeholder file body\n"
111
- ans = f"{f} is a small placeholder; nothing surprising."
112
- return _Row(
113
- user_prompt=prompt,
114
- pre_text=pre,
115
- tool_name="Read",
116
- tool_args={"file_path": f},
117
- tool_result=body,
118
- final_text=ans,
119
- )
120
-
121
-
122
- def _grep_row(rng: random.Random) -> _Row:
123
- pat = rng.choice(_PATTERNS)
124
- where = rng.choice(_DIRS)
125
- template = rng.choice([
126
- ("find {pat} in {where}", "let me search"),
127
- ("where is {pat} used in {where}?", "grepping"),
128
- ("search for {pat} under {where}", "ok"),
129
- ("any files containing {pat}?", "let me check"),
130
- ("show me uses of {pat} in {where}", "searching"),
131
- ("does {pat} appear anywhere?", "looking"),
132
- ("locate {pat} in the codebase", "grepping the tree"),
133
- ("which files reference {pat}?", "checking"),
134
- ("grep {pat}", "running grep"),
135
- ("are there any {pat} markers?", "let me see"),
136
- ])
137
- prompt = template[0].format(pat=pat, where=where)
138
- pre = template[1]
139
- n_hits = rng.randint(1, 4)
140
- hits = "\n".join(
141
- f"{rng.choice(_FILES_PY)}:{rng.randint(1, 200)}: {pat}"
142
- for _ in range(n_hits)
143
- )
144
- if n_hits == 0:
145
- ans = f"no matches for {pat}."
146
- elif n_hits == 1:
147
- ans = f"one hit for {pat} — single-callsite, easy to update."
148
- else:
149
- ans = f"{n_hits} matches for {pat} across the tree; the {pat.split()[0] if ' ' in pat else pat} pattern is consistent."
150
- return _Row(
151
- user_prompt=prompt,
152
- pre_text=pre,
153
- tool_name="Grep",
154
- tool_args={"pattern": pat, "path": where},
155
- tool_result=hits or "(no matches)",
156
- final_text=ans,
157
- )
158
-
159
-
160
- def _bash_row(rng: random.Random) -> _Row:
161
- f = rng.choice(_FILES_PY + _FILES_DATA)
162
- d = rng.choice(_DIRS)
163
- choice = rng.choice([
164
- ("list", f"list files in {d}", "ls", f"ls {d}",
165
- "main.py app.py utils.py tests"),
166
- ("count", f"count lines in {f}", "wc", f"wc -l {f}",
167
- f"{rng.randint(20, 800)} {f}"),
168
- ("size", f"what is the size of {f}?", "du", f"du -h {f}",
169
- f"{rng.randint(2, 99)}K\t{f}"),
170
- ("first", f"show first 5 lines of {f}", "head", f"head -n 5 {f}",
171
- "line 1\nline 2\nline 3\nline 4\nline 5"),
172
- ("last", f"show the last 10 lines of {f}", "tail", f"tail -n 10 {f}",
173
- "tail line a\ntail line b\ntail line c"),
174
- ("exists", f"does {f} exist?", "test", f"test -f {f} && echo yes || echo no",
175
- "yes"),
176
- ("find_large", f"find files larger than 1mb under {d}", "find", f"find {d} -size +1M",
177
- f"{d}/data/big.bin"),
178
- ("mod_time", f"when was {f} last modified?", "stat", f"stat -c %y {f}",
179
- "2026-05-06 14:22:11.000000000 +0000"),
180
- ("grep_simple", f"any errors in {f}?", "grep", f"grep -i error {f}",
181
- f"{f}:10: raise ValueError('boom')"),
182
- ])
183
- kind, prompt, _short, cmd, body = choice
184
- pre = rng.choice(["running it", "let me check", "ok", "checking"])
185
- ans_map = {
186
- "list": f"the {d} directory has 4 files: main.py, app.py, utils.py, and tests.",
187
- "count": f"{f} is {body.split()[0]} lines.",
188
- "size": f"{f} is {body.split(chr(9))[0]}.",
189
- "first": f"first 5 lines are 'line 1' through 'line 5'.",
190
- "last": "last lines are 'tail line a/b/c'.",
191
- "exists": f"{f} exists.",
192
- "find_large": f"one file over 1MB: {body}.",
193
- "mod_time": "last modified 2026-05-06 at 14:22.",
194
- "grep_simple": f"one error path on line 10 raises ValueError.",
195
- }
196
- return _Row(
197
- user_prompt=prompt,
198
- pre_text=pre,
199
- tool_name="Bash",
200
- tool_args={"command": cmd},
201
- tool_result=body,
202
- final_text=ans_map[kind],
203
- )
204
-
205
-
206
- def _edit_row(rng: random.Random) -> _Row:
207
- f = rng.choice(_FILES_PY)
208
- edit = rng.choice([
209
- ("change_version", f"bump version in {f}",
210
- "version = '0.1.0'", "version = '0.2.0'",
211
- "version = '0.2.0'"),
212
- ("add_docstring", f"add a one-line docstring to {f}",
213
- "def main():", "def main():\n \"\"\"entry point\"\"\"",
214
- "def main():\n \"\"\"entry point\"\"\""),
215
- ("rename_var", f"rename `tmp` to `result` in {f}",
216
- "tmp = compute()", "result = compute()",
217
- "result = compute()"),
218
- ("add_import", f"add `import logging` at the top of {f}",
219
- "", "import logging",
220
- "import logging"),
221
- ("fix_typo", f"fix the typo `recieve` → `receive` in {f}",
222
- "def recieve(x):", "def receive(x):",
223
- "def receive(x):"),
224
- ("create", f"create a new file {f} that prints hello",
225
- "", "print('hello')",
226
- "print('hello')"),
227
- ("replace_print", f"replace print() with logger.info() in {f}",
228
- "print(x)", "logger.info(x)",
229
- "logger.info(x)"),
230
- ("add_main_guard", f"wrap the body of {f} in if __name__ == '__main__':",
231
- "main()", "if __name__ == '__main__':\n main()",
232
- "if __name__ == '__main__':\n main()"),
233
- ])
234
- kind, prompt, old, new, applied = edit
235
- pre = rng.choice([
236
- "applying the edit", "ok", "let me make that change",
237
- "editing", "doing it now",
238
- ])
239
- args = {"file_path": f, "new_string": new}
240
- if old:
241
- args["old_string"] = old
242
- body = f"applied the edit to {f}: now contains:\n{applied}"
243
- ans = rng.choice([
244
- f"done — {f} now uses the updated form.",
245
- f"applied. {f} is updated.",
246
- "edit applied successfully.",
247
- f"updated {f} as requested.",
248
- ])
249
- return _Row(
250
- user_prompt=prompt,
251
- pre_text=pre,
252
- tool_name="Edit",
253
- tool_args=args,
254
- tool_result=body,
255
- final_text=ans,
256
- )
257
-
258
-
259
- _GENERATORS: dict[str, Any] = {
260
- "Read": _read_row,
261
- "Grep": _grep_row,
262
- "Bash": _bash_row,
263
- "Edit": _edit_row,
264
- }
265
-
266
-
267
- # ---- Row builder -----------------------------------------------------------
268
-
269
-
270
- def _row_to_messages(r: _Row) -> dict[str, Any]:
271
- return {
272
- "messages": [
273
- {"role": "user", "content": r.user_prompt},
274
- {
275
- "role": "assistant",
276
- "content": r.pre_text,
277
- "tool_call": {"name": r.tool_name, "args": r.tool_args},
278
- },
279
- {"role": "tool_result", "content": r.tool_result},
280
- {"role": "assistant", "content": r.final_text},
281
- ]
282
- }
283
-
284
-
285
- def generate_routing_rows(
286
- seed: int = 42,
287
- n_per_tool: int = 200,
288
- tools: tuple[str, ...] = ("Read", "Grep", "Bash", "Edit"),
289
- ) -> list[dict[str, Any]]:
290
- """Generate the routing-recovery rows in memory.
291
-
292
- Deterministic per `seed`; produces `n_per_tool * len(tools)` rows total.
293
- """
294
- rng = random.Random(seed)
295
- out: list[dict[str, Any]] = []
296
- for tool in tools:
297
- gen = _GENERATORS[tool]
298
- for i in range(n_per_tool):
299
- row = gen(random.Random(seed * 1_000_003 + hash(tool) + i))
300
- out.append(_row_to_messages(row))
301
- rng.shuffle(out)
302
- return out
303
-
304
-
305
- # ---- SFT-side dataset class ------------------------------------------------
306
-
307
-
308
- class RoutingRecoveryDataset:
309
- """In-memory dataset usable by `agentic_sft.py` task mixtures.
310
-
311
- Mirrors the `JSONDataset` protocol (len + getitem + system prompt prepend).
312
- Generated synthetically — no on-disk file required, but the CLI below can
313
- write a JSONL artefact for inspection / reproducibility.
314
- """
315
-
316
- def __init__(
317
- self,
318
- seed: int = 42,
319
- n_per_tool: int = 200,
320
- tools: tuple[str, ...] = ("Read", "Grep", "Bash", "Edit"),
321
- **_: Any,
322
- ) -> None:
323
- self.ds = generate_routing_rows(seed=seed, n_per_tool=n_per_tool, tools=tools)
324
-
325
- def __len__(self) -> int:
326
- return len(self.ds)
327
-
328
- def __getitem__(self, idx: int) -> dict[str, Any]:
329
- messages = self.ds[idx]["messages"]
330
- return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + messages}
331
-
332
-
333
- # ---- CLI -------------------------------------------------------------------
334
-
335
-
336
- def _main() -> None:
337
- p = argparse.ArgumentParser()
338
- p.add_argument("--output", required=True, type=Path,
339
- help="Output JSONL path (one {messages:[...]} per line)")
340
- p.add_argument("--seed", type=int, default=42)
341
- p.add_argument("--n-per-tool", type=int, default=200,
342
- help="Rows per tool family (default 200 → 800 total)")
343
- p.add_argument("--tools", type=str, default="Read,Grep,Bash,Edit",
344
- help="Comma-separated tool families to generate")
345
- args = p.parse_args()
346
-
347
- tools = tuple(t.strip() for t in args.tools.split(",") if t.strip())
348
- rows = generate_routing_rows(seed=args.seed, n_per_tool=args.n_per_tool, tools=tools)
349
- args.output.parent.mkdir(parents=True, exist_ok=True)
350
- with args.output.open("w", encoding="utf-8") as f:
351
- for row in rows:
352
- f.write(json.dumps(row, ensure_ascii=False) + "\n")
353
- print(f"wrote {len(rows)} rows ({args.n_per_tool}/tool × {len(tools)} tools) → {args.output}")
354
-
355
-
356
- if __name__ == "__main__":
357
- _main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/sft_data_1b.py DELETED
@@ -1,240 +0,0 @@
1
- """1B-challenge SFT data: UltraData-SFT-2605 -> hybrid-thinking (ids, mask).
2
-
3
- Self-contained, mirrors the pretrain_mix_1b approach (zero change to validated
4
- code). Two responsibilities:
5
-
6
- 1. ``render_sft_example`` — turn ONE UltraData example into ``(ids, mask)`` with
7
- the **Qwen3/MiniCPM-style hybrid** layout so a single ckpt can switch
8
- think/no-think reliably:
9
- think : <|assistant_start|> <|think_start|> {reasoning} <|think_end|> {answer} <|assistant_end|>
10
- no_think : <|assistant_start|> <|think_start|> <|think_end|> {answer} <|assistant_end|> (EMPTY think block)
11
- **mode_tag (hybrid-think switch fix)**: with ``mode_tag=True`` the user turn is
12
- suffixed with `` /think`` (think examples) or `` /no_think`` (no_think), mask=0.
13
- Without this prompt-side signal the think/no_think prompts are IDENTICAL, so a
14
- greedy decoder cannot tell the modes apart and collapses to the majority
15
- no-think path (empty <|think_start|><|think_end|>, reasoning leaks into the
16
- answer body). The tag is the deterministic control the model conditions on;
17
- at inference append the same `` /think``/`` /no_think`` to select the mode.
18
- ``mask=1`` on every assistant token the model must GENERATE (think block —
19
- incl. the empty one — + answer + assistant_end). **think tokens are emitted
20
- via encode_special, NEVER as literal "<|think_start|>" text** (literal text
21
- goes through encode_ordinary and is NOT the special token — the footgun that
22
- tanked KSR Phase 3; see lesson_think_tokens_unencoded).
23
-
24
- 2. ``iter_and_sample`` / ``prepare_sft_cache`` — stream the 7 gated configs with
25
- the HF token, **budget-match to TARGET TOKEN shares** (render to count tokens),
26
- keep the ~think/no_think ratio, run a light eval-set decontam, and write the
27
- sampled raw conversations to a jsonl cache. The SFT trainer renders on the fly
28
- (like chat_sft) at max_seq_len=32768.
29
-
30
- UltraData schema (confirmed): configs {Math,Code,Knowledge,Chinese-general,IF,
31
- Multi-lang-Math,Multi-lang-Knowledge}; splits {think,no_think}; per example
32
- ``messages=[{role,content,reasoning_content}]`` (reasoning_content = long CoT on
33
- the assistant turn for think; null for no_think), plus uid/source/domain/think_type.
34
- """
35
- from __future__ import annotations
36
-
37
- import argparse
38
- import json
39
- import os
40
- import random
41
-
42
- # ---------------------------------------------------------------- rendering
43
-
44
- def _normalize_messages(messages):
45
- """Merge a leading system message into the first user turn (mirrors
46
- tokenizer.render_conversation); return the cleaned message list."""
47
- if messages and messages[0].get("role") == "system":
48
- if len(messages) >= 2 and messages[1].get("role") == "user":
49
- merged = dict(messages[1])
50
- merged["content"] = (messages[0].get("content") or "") + "\n\n" + (messages[1].get("content") or "")
51
- return [merged] + list(messages[2:])
52
- return list(messages[1:])
53
- return list(messages)
54
-
55
-
56
- # Prompt-side hybrid-think control tags (Qwen3-style). Appended to user turns
57
- # when mode_tag=True so the model conditions on the requested mode. Import these
58
- # in inference/eval code to activate thinking deterministically.
59
- THINK_TAG = " /think"
60
- NOTHINK_TAG = " /no_think"
61
-
62
-
63
- def render_sft_example(tokenizer, messages, think: bool, max_tokens: int = 32768,
64
- mode_tag: bool = False):
65
- """One UltraData example -> (ids, mask). See module docstring for layout.
66
-
67
- mode_tag=True appends the prompt-side ``/think`` / ``/no_think`` switch to the
68
- user turn (mask=0) so the hybrid-think mode is controllable instead of
69
- collapsing to empty-think under greedy decoding.
70
- """
71
- ids, mask = [], []
72
-
73
- def add(toks, m):
74
- if isinstance(toks, int):
75
- toks = [toks]
76
- ids.extend(toks)
77
- mask.extend([m] * len(toks))
78
-
79
- bos = tokenizer.get_bos_token_id()
80
- us = tokenizer.encode_special("<|user_start|>")
81
- ue = tokenizer.encode_special("<|user_end|>")
82
- as_ = tokenizer.encode_special("<|assistant_start|>")
83
- ae = tokenizer.encode_special("<|assistant_end|>")
84
- ts = tokenizer.encode_special("<|think_start|>")
85
- te = tokenizer.encode_special("<|think_end|>")
86
-
87
- add(bos, 0)
88
- for m in _normalize_messages(messages):
89
- role = m.get("role")
90
- content = m.get("content") or ""
91
- if role == "user":
92
- add(us, 0)
93
- if mode_tag:
94
- content = content + (THINK_TAG if think else NOTHINK_TAG)
95
- add(tokenizer.encode(content), 0)
96
- add(ue, 0)
97
- elif role == "assistant":
98
- add(as_, 0)
99
- # think block (empty for no_think) — masked 1 so the switch is learned
100
- add(ts, 1)
101
- if think:
102
- rc = m.get("reasoning_content") or ""
103
- if rc:
104
- add(tokenizer.encode(rc), 1)
105
- add(te, 1)
106
- # final answer — masked 1
107
- if content:
108
- add(tokenizer.encode(content), 1)
109
- add(ae, 1)
110
- # any other role is skipped (UltraData is user/assistant only)
111
- return ids[:max_tokens], mask[:max_tokens]
112
-
113
-
114
- # ---------------------------------------------------------------- sampling
115
-
116
- # TARGET TOKEN shares across configs (sft_design §2). Multi-lang skipped in v1.
117
- DEFAULT_MIX = {"Math": 0.38, "Code": 0.32, "Knowledge": 0.12,
118
- "Chinese-general": 0.10, "IF": 0.08}
119
- # GENERAL-PURPOSE mix: turns on the UltraData Multi-lang configs (~17% multilingual SFT) for the
120
- # general_1b recipe, rebalanced to sum to 1.0. Leaves DEFAULT_MIX (the 1b-challenge v1 recipe)
121
- # untouched — selected via `prepare_sft_cache --mix general`.
122
- GENERAL_MIX = {"Math": 0.30, "Code": 0.25, "Knowledge": 0.12, "Chinese-general": 0.08,
123
- "IF": 0.08, "Multi-lang-Math": 0.09, "Multi-lang-Knowledge": 0.08}
124
- MIXES = {"default": DEFAULT_MIX, "general": GENERAL_MIX}
125
- THINK_RATIO = 0.43 # ~43% thinking / 57% non-thinking (UltraData global)
126
-
127
-
128
- def _decontam_ngrams(eval_texts, n=13):
129
- grams = set()
130
- for t in eval_texts:
131
- toks = t.split()
132
- for i in range(len(toks) - n + 1):
133
- grams.add(" ".join(toks[i:i + n]))
134
- return grams
135
-
136
-
137
- def _is_contaminated(messages, bad_grams, n=13):
138
- if not bad_grams:
139
- return False
140
- txt = " ".join((m.get("content") or "") for m in messages)
141
- toks = txt.split()
142
- for i in range(len(toks) - n + 1):
143
- if " ".join(toks[i:i + n]) in bad_grams:
144
- return True
145
- return False
146
-
147
-
148
- def iter_and_sample(tokenizer, *, mix=None, think_ratio=THINK_RATIO,
149
- total_token_budget=2_000_000_000, max_tokens=32768,
150
- seed=0, hf_token=None, decontam_grams=None, log=print):
151
- """Stream UltraData, render to count tokens, yield sampled raw examples
152
- (dicts with messages/think/domain/source/uid/n_tokens) until each config's
153
- TOKEN sub-budget (mix * budget, split think_ratio) is met."""
154
- from datasets import load_dataset
155
- mix = mix or DEFAULT_MIX
156
- rng = random.Random(seed)
157
- if hf_token:
158
- os.environ.setdefault("HF_TOKEN", hf_token)
159
- os.environ.setdefault("HUGGING_FACE_HUB_TOKEN", hf_token)
160
-
161
- for cfg, share in mix.items():
162
- cfg_budget = total_token_budget * share
163
- for split, frac in (("think", think_ratio), ("no_think", 1.0 - think_ratio)):
164
- sub_budget = cfg_budget * frac
165
- got_tok = 0
166
- n_skip_contam = 0
167
- ds = load_dataset("openbmb/UltraData-SFT-2605", cfg, split=split,
168
- streaming=True)
169
- ds = ds.shuffle(seed=seed + hash(cfg + split) % 1000, buffer_size=10000)
170
- n_err = 0
171
- for ex in ds:
172
- if got_tok >= sub_budget:
173
- break
174
- try:
175
- msgs = ex.get("messages") or []
176
- if not msgs:
177
- continue
178
- if decontam_grams and _is_contaminated(msgs, decontam_grams):
179
- n_skip_contam += 1
180
- continue
181
- think = (split == "think")
182
- ids, _ = render_sft_example(tokenizer, msgs, think, max_tokens)
183
- n = len(ids)
184
- if n < 8:
185
- continue
186
- except Exception: # one malformed row must not kill an hour-long prep
187
- n_err += 1
188
- if n_err <= 3:
189
- log(f"[sft_data] {cfg}/{split}: skipped bad row ({n_err})")
190
- continue
191
- got_tok += n
192
- yield {"messages": msgs, "think": think, "domain": ex.get("domain", cfg),
193
- "source": ex.get("source", ""), "uid": ex.get("uid", ""),
194
- "n_tokens": n}
195
- log(f"[sft_data] {cfg}/{split}: ~{got_tok:,} tok "
196
- f"(budget {sub_budget:,.0f}), decontam-skipped {n_skip_contam}")
197
-
198
-
199
- def prepare_sft_cache(out_path, tokenizer, *, total_token_budget, max_tokens=32768,
200
- seed=0, hf_token=None, eval_texts=None, mix=None):
201
- """Write budget-matched sampled conversations to a jsonl cache (raw messages;
202
- the trainer re-renders on the fly). Returns summary dict."""
203
- decontam = _decontam_ngrams(eval_texts) if eval_texts else None
204
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
205
- n_ex = tot_tok = 0
206
- by_domain, by_think = {}, {"think": 0, "no_think": 0}
207
- with open(out_path, "w") as f:
208
- for row in iter_and_sample(tokenizer, mix=mix, total_token_budget=total_token_budget,
209
- max_tokens=max_tokens, seed=seed, hf_token=hf_token,
210
- decontam_grams=decontam):
211
- f.write(json.dumps(row, ensure_ascii=False) + "\n")
212
- n_ex += 1
213
- tot_tok += row["n_tokens"]
214
- by_domain[row["domain"]] = by_domain.get(row["domain"], 0) + row["n_tokens"]
215
- by_think["think" if row["think"] else "no_think"] += row["n_tokens"]
216
- summary = {"out": out_path, "n_examples": n_ex, "total_tokens": tot_tok,
217
- "by_domain_tokens": by_domain, "by_think_tokens": by_think}
218
- print("[sft_data] CACHE SUMMARY:", json.dumps(summary, ensure_ascii=False))
219
- return summary
220
-
221
-
222
- def main():
223
- ap = argparse.ArgumentParser()
224
- ap.add_argument("--out", required=True, help="jsonl cache path")
225
- ap.add_argument("--token-budget", type=float, default=2e9)
226
- ap.add_argument("--max-tokens", type=int, default=32768)
227
- ap.add_argument("--seed", type=int, default=0)
228
- ap.add_argument("--hf-token", default=os.environ.get("HF_TOKEN", ""))
229
- ap.add_argument("--mix", choices=sorted(MIXES), default="default",
230
- help="SFT config mix: 'default' (1b-challenge v1) or 'general' (+multilingual)")
231
- args = ap.parse_args()
232
- from nanoai.tokenizer import get_tokenizer
233
- tok = get_tokenizer()
234
- prepare_sft_cache(args.out, tok, total_token_budget=int(args.token_budget),
235
- max_tokens=args.max_tokens, seed=args.seed,
236
- hf_token=args.hf_token or None, mix=MIXES[args.mix])
237
-
238
-
239
- if __name__ == "__main__":
240
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/synth_data.py DELETED
@@ -1,74 +0,0 @@
1
- """SYNTH (PleIAs) parquet iterator for d6 ablation pretrain.
2
-
3
- Concatenates each row's `query` + `synthetic_reasoning` + `synthetic_answer`
4
- into a single training document string. Mirrors the iterator interface used
5
- by `pretrain_data.py` so the BOS-aligned bestfit packer can consume it as a
6
- drop-in document stream.
7
-
8
- Default location: $NANOAI_BASE_DIR/code_pretrain_data/synth-pleias/
9
- (can be a symlink to the actual SYNTH dir).
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from pathlib import Path
15
-
16
- import pyarrow.parquet as pq
17
-
18
- from nanoai.common import get_base_dir
19
-
20
-
21
- DATA_DIR = Path(get_base_dir()) / "code_pretrain_data" / "synth-pleias"
22
-
23
-
24
- def list_synth_parquet_files() -> list[Path]:
25
- if not DATA_DIR.exists():
26
- return []
27
- return sorted([DATA_DIR / f for f in DATA_DIR.iterdir() if f.suffix == ".parquet"])
28
-
29
-
30
- _CONCAT_SEP = "\n\n"
31
-
32
-
33
- def _row_to_text(row: dict) -> str:
34
- q = row.get("query") or ""
35
- r = row.get("synthetic_reasoning") or ""
36
- a = row.get("synthetic_answer") or ""
37
- return _CONCAT_SEP.join(x for x in (q, r, a) if x)
38
-
39
-
40
- def synth_doc_batches(
41
- paths: list[Path],
42
- ddp_rank: int,
43
- ddp_world_size: int,
44
- tokenizer_batch_size: int,
45
- ):
46
- """Infinite per-rank iterator: yields list[str] doc batches.
47
-
48
- Each parquet row group is striped across ranks. Each row yields one
49
- concatenated `query \\n\\n reasoning \\n\\n answer` document.
50
- """
51
- cols = ["query", "synthetic_reasoning", "synthetic_answer"]
52
- while True:
53
- for filepath in paths:
54
- pf = pq.ParquetFile(filepath)
55
- for rg_idx in range(ddp_rank, pf.num_row_groups, ddp_world_size):
56
- rg = pf.read_row_group(rg_idx, columns=cols)
57
- rows = rg.to_pylist()
58
- batch: list[str] = []
59
- for r in rows:
60
- txt = _row_to_text(r)
61
- if not txt:
62
- continue
63
- batch.append(txt)
64
- if len(batch) >= tokenizer_batch_size:
65
- yield batch
66
- batch = []
67
- if batch:
68
- yield batch
69
-
70
-
71
- def split_paths(paths: list[Path], split: str) -> list[Path]:
72
- if not paths:
73
- return []
74
- return paths[:-1] if split == "train" else paths[-1:]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/tool_chain_demo.py DELETED
@@ -1,563 +0,0 @@
1
- """Synthetic multi-step tool-chain demonstration dataset.
2
-
3
- Motivation (POC-9 mechanistic analysis):
4
- v11 (routing_rows=200) lifted single-tool routing accuracy but did NOT
5
- recover bash_grep_log — the diagnostic regression task that requires
6
- CHAINING bash → grep → read. routing_recovery teaches "given prompt P,
7
- call tool T". It cannot teach "given prompt P, call T1, then read its
8
- output, then call T2 with args derived from T1's output, then ...".
9
-
10
- This dataset adds explicit multi-step tool-chain trajectories. Each
11
- row is a 6-10 message conversation showing: user prompt → tool call →
12
- tool result → tool call → tool result → ... → final answer. The
13
- intermediate assistant turns reference what the previous tool result
14
- said, modeling the read-act-observe loop.
15
-
16
- Format on disk: JSONL, one `{"messages": [...]}` per line, compatible
17
- with `render_agentic_conversation`'s tool-call branch.
18
-
19
- Schema invariants (verified by audit):
20
- - first message role=user, last role=assistant (no tool_call)
21
- - intermediate assistants alternate with tool_results
22
- - every assistant with tool_call has args as a dict
23
- - row text deterministic for fixed (seed, chain_kind, idx)
24
-
25
- CLI:
26
- python -m nanoai.agentic.data.tool_chain_demo \
27
- --output data/tool_chain_demo.jsonl --seed 42 --n-per-chain 100
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- import argparse
33
- import json
34
- import random
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Any
38
-
39
- from nanoai.agentic.data.common import SYSTEM_PROMPT
40
-
41
-
42
- # ---- Corpora ---------------------------------------------------------------
43
-
44
- _FILES_PY = [
45
- "main.py", "app.py", "lib.py", "utils.py", "config.py", "server.py",
46
- "client.py", "models.py", "schema.py", "data.py", "tools.py",
47
- "parser.py", "router.py", "loader.py", "engine.py", "core.py",
48
- "common.py", "tasks.py", "errors.py", "handlers.py", "auth.py",
49
- ]
50
- _FILES_DATA = [
51
- "config.toml", "settings.json", "data.csv", "users.json", "log.txt",
52
- "README.md", "Makefile", "requirements.txt", "pyproject.toml",
53
- "errors.log", "access.log", "metrics.tsv", "manifest.yaml",
54
- "deploy.yaml", "schema.sql",
55
- ]
56
- _DIRS = ["src", "lib", "tests", "scripts", "data", "config", "core", "app"]
57
- _PATTERNS = [
58
- "TODO", "FIXME", "ERROR", "WARN", "import torch",
59
- "def __init__", "raise ValueError", "logger.info",
60
- "self.config", "from typing import", "@dataclass",
61
- ]
62
-
63
-
64
- # ---- Step + Row dataclasses ------------------------------------------------
65
-
66
-
67
- @dataclass
68
- class _Step:
69
- """One assistant→tool round-trip inside a chain."""
70
- pre_text: str
71
- tool_name: str
72
- tool_args: dict[str, str]
73
- tool_result: str
74
-
75
-
76
- @dataclass
77
- class _Row:
78
- user_prompt: str
79
- steps: list[_Step]
80
- final_text: str
81
- chain_kind: str = ""
82
- extra: dict[str, Any] = field(default_factory=dict)
83
-
84
-
85
- # ---- Chain generators ------------------------------------------------------
86
- #
87
- # Each generator returns one _Row. They share a per-row rng so determinism
88
- # holds: identical (seed, chain_kind, idx) → identical bytes.
89
-
90
-
91
- def _bash_grep_read_row(rng: random.Random) -> _Row:
92
- """bash → grep → read. Direct attack on bash_grep_log task family."""
93
- d = rng.choice(_DIRS)
94
- pat = rng.choice(_PATTERNS)
95
- target = rng.choice(_FILES_PY)
96
- n_files = rng.randint(2, 5)
97
- listing_files = rng.sample(_FILES_PY, n_files)
98
- if target not in listing_files:
99
- listing_files[0] = target
100
- listing = "\n".join(f"{d}/{f}" for f in listing_files)
101
- grep_hits = "\n".join(
102
- f"{d}/{target}:{rng.randint(5, 200)}: {pat}"
103
- for _ in range(rng.randint(1, 3))
104
- )
105
- read_body = (
106
- f"# {target}\n"
107
- f"def handler():\n"
108
- f" # {pat} fix the broken case\n"
109
- f" raise ValueError('boom')\n"
110
- )
111
- user = rng.choice([
112
- f"in {d}/, find python files containing {pat} and show me the relevant file",
113
- f"which file under {d} mentions {pat}? then read it and explain",
114
- f"locate {pat} in {d} and tell me what the surrounding code does",
115
- f"search for {pat} in {d} python files and read the offending one",
116
- ])
117
- return _Row(
118
- user_prompt=user,
119
- steps=[
120
- _Step(
121
- pre_text=f"let me list the files under {d} first",
122
- tool_name="Bash",
123
- tool_args={"command": f"ls {d}/*.py"},
124
- tool_result=listing,
125
- ),
126
- _Step(
127
- pre_text=f"now grep for {pat} across those files",
128
- tool_name="Grep",
129
- tool_args={"pattern": pat, "path": d},
130
- tool_result=grep_hits,
131
- ),
132
- _Step(
133
- pre_text=f"reading {d}/{target} where the hit is",
134
- tool_name="Read",
135
- tool_args={"file_path": f"{d}/{target}"},
136
- tool_result=read_body,
137
- ),
138
- ],
139
- final_text=(
140
- f"{target} has a {pat} marker on a `handler()` that raises ValueError. "
141
- f"the relevant block is the broken-case path."
142
- ),
143
- chain_kind="bash_grep_read",
144
- )
145
-
146
-
147
- def _bash_read_row(rng: random.Random) -> _Row:
148
- """bash → read. e.g. find largest file then read it."""
149
- d = rng.choice(_DIRS)
150
- target = rng.choice(_FILES_PY)
151
- sizes = sorted(
152
- [(rng.randint(1, 80), rng.choice(_FILES_PY)) for _ in range(4)],
153
- reverse=True,
154
- )
155
- if sizes[0][1] == target:
156
- winner = sizes[0]
157
- else:
158
- winner = (sizes[0][0], target)
159
- listing = "\n".join(f"{sz}K\t{d}/{name}" for sz, name in sizes[:3] + [winner])
160
- read_body = (
161
- f"# {target}\n"
162
- f"class Engine:\n"
163
- f" def run(self):\n"
164
- f" return 'ok'\n"
165
- )
166
- user = rng.choice([
167
- f"find the largest python file under {d}/ and show me what it does",
168
- f"which file in {d} is biggest? read it and summarize",
169
- f"check disk usage of {d}/*.py, then open the largest one",
170
- ])
171
- return _Row(
172
- user_prompt=user,
173
- steps=[
174
- _Step(
175
- pre_text=f"checking sizes under {d}",
176
- tool_name="Bash",
177
- tool_args={"command": f"du -k {d}/*.py | sort -rn | head -5"},
178
- tool_result=listing,
179
- ),
180
- _Step(
181
- pre_text=f"the largest is {target}, reading it",
182
- tool_name="Read",
183
- tool_args={"file_path": f"{d}/{target}"},
184
- tool_result=read_body,
185
- ),
186
- ],
187
- final_text=(
188
- f"{target} is the largest under {d} ({winner[0]}K). it defines an "
189
- f"`Engine` class with a `run()` method that returns 'ok'."
190
- ),
191
- chain_kind="bash_read",
192
- )
193
-
194
-
195
- def _grep_read_row(rng: random.Random) -> _Row:
196
- """grep → read. Find a hit, then open the file to inspect context."""
197
- pat = rng.choice(_PATTERNS)
198
- target = rng.choice(_FILES_PY)
199
- where = rng.choice(_DIRS)
200
- line = rng.randint(10, 200)
201
- hits = (
202
- f"{where}/{target}:{line}: {pat}\n"
203
- f"{where}/other.py:{rng.randint(5, 50)}: {pat}"
204
- )
205
- read_body = (
206
- f"# {target} excerpt around line {line}\n"
207
- f"def call_site():\n"
208
- f" # {pat}: under review\n"
209
- f" return None\n"
210
- )
211
- user = rng.choice([
212
- f"grep for {pat} under {where}/ and show me the first hit's surrounding code",
213
- f"where does {pat} appear in {where}? open the file and explain",
214
- f"find {pat} in {where} python files and read the matching one",
215
- f"search {pat} in {where}/ then dump the file context",
216
- ])
217
- return _Row(
218
- user_prompt=user,
219
- steps=[
220
- _Step(
221
- pre_text="grepping",
222
- tool_name="Grep",
223
- tool_args={"pattern": pat, "path": where},
224
- tool_result=hits,
225
- ),
226
- _Step(
227
- pre_text=f"top hit is {where}/{target}:{line}, reading it",
228
- tool_name="Read",
229
- tool_args={"file_path": f"{where}/{target}"},
230
- tool_result=read_body,
231
- ),
232
- ],
233
- final_text=(
234
- f"{target} has the {pat} marker on a `call_site()` function "
235
- f"around line {line}; it's an inert TODO-style note."
236
- ),
237
- chain_kind="grep_read",
238
- )
239
-
240
-
241
- def _read_edit_row(rng: random.Random) -> _Row:
242
- """read → edit. Open a file, make a targeted change."""
243
- target = rng.choice(_FILES_PY + ["config.py", "config.toml"])
244
- edit = rng.choice([
245
- ("bump_version",
246
- f"version = '0.1.0'", "version = '0.2.0'",
247
- "bump the version to 0.2.0"),
248
- ("rename_var",
249
- "tmp = compute()", "result = compute()",
250
- "rename `tmp` to `result`"),
251
- ("add_docstring",
252
- "def main():", "def main():\n \"\"\"entry point\"\"\"",
253
- "add a one-line docstring to main()"),
254
- ("fix_typo",
255
- "def recieve(x):", "def receive(x):",
256
- "fix the typo recieve → receive"),
257
- ("replace_print",
258
- "print(x)", "logger.info(x)",
259
- "replace the print() with logger.info()"),
260
- ])
261
- kind, old, new, ask = edit
262
- read_body = f"# {target}\n{old}\n# (other lines)\n"
263
- user = rng.choice([
264
- f"in {target}, {ask}",
265
- f"open {target} and {ask}",
266
- f"please {ask} in {target}",
267
- ])
268
- return _Row(
269
- user_prompt=user,
270
- steps=[
271
- _Step(
272
- pre_text=f"reading {target} first",
273
- tool_name="Read",
274
- tool_args={"file_path": target},
275
- tool_result=read_body,
276
- ),
277
- _Step(
278
- pre_text=f"applying the edit ({kind})",
279
- tool_name="Edit",
280
- tool_args={
281
- "file_path": target,
282
- "old_string": old,
283
- "new_string": new,
284
- },
285
- tool_result=f"applied edit to {target}: replaced 1 occurrence.",
286
- ),
287
- ],
288
- final_text=f"done — {target} now has the {kind.replace('_', ' ')} applied.",
289
- chain_kind="read_edit",
290
- )
291
-
292
-
293
- def _read_bash_row(rng: random.Random) -> _Row:
294
- """read → bash. Read a script to find the right invocation, then run."""
295
- target = rng.choice(["run.sh", "build.sh", "test.sh", "deploy.sh"])
296
- flag = rng.choice(["--verbose", "--dry-run", "--prod", "--config=staging"])
297
- read_body = (
298
- f"#!/usr/bin/env bash\n"
299
- f"# {target}\n"
300
- f"# Usage: {target} {flag}\n"
301
- f"echo running with {flag}\n"
302
- )
303
- bash_out = f"running with {flag}\n"
304
- user = rng.choice([
305
- f"check what flag {target} expects, then run it",
306
- f"read {target} and execute it with the right argument",
307
- f"open {target} to find the usage line, then run it accordingly",
308
- ])
309
- return _Row(
310
- user_prompt=user,
311
- steps=[
312
- _Step(
313
- pre_text=f"reading {target} to find the flag",
314
- tool_name="Read",
315
- tool_args={"file_path": target},
316
- tool_result=read_body,
317
- ),
318
- _Step(
319
- pre_text=f"running it with {flag}",
320
- tool_name="Bash",
321
- tool_args={"command": f"bash {target} {flag}"},
322
- tool_result=bash_out,
323
- ),
324
- ],
325
- final_text=f"ran {target} with {flag} — output confirms the run completed.",
326
- chain_kind="read_bash",
327
- )
328
-
329
-
330
- def _read_grep_read_row(rng: random.Random) -> _Row:
331
- """read → grep → read. Open a manifest, grep for a referenced symbol,
332
- open the file containing the symbol."""
333
- manifest = rng.choice(["README.md", "manifest.yaml", "pyproject.toml"])
334
- entry = rng.choice(_FILES_PY)
335
- symbol = rng.choice(["main", "run", "handler", "boot", "serve"])
336
- target = rng.choice(_FILES_PY)
337
- line = rng.randint(10, 100)
338
- manifest_body = (
339
- f"# {manifest}\n"
340
- f"entry: {entry}\n"
341
- f"main_symbol: {symbol}\n"
342
- )
343
- grep_hits = f"{target}:{line}: def {symbol}():"
344
- read_body = (
345
- f"# {target}\n"
346
- f"def {symbol}():\n"
347
- f" return 'started'\n"
348
- )
349
- user = rng.choice([
350
- f"open {manifest}, find the main symbol, then locate its definition",
351
- f"read {manifest} to find the entry symbol, then grep for and read it",
352
- f"check {manifest} for the main_symbol, then track it down in source",
353
- ])
354
- return _Row(
355
- user_prompt=user,
356
- steps=[
357
- _Step(
358
- pre_text=f"reading {manifest}",
359
- tool_name="Read",
360
- tool_args={"file_path": manifest},
361
- tool_result=manifest_body,
362
- ),
363
- _Step(
364
- pre_text=f"grepping for `def {symbol}`",
365
- tool_name="Grep",
366
- tool_args={"pattern": f"def {symbol}", "path": "."},
367
- tool_result=grep_hits,
368
- ),
369
- _Step(
370
- pre_text=f"reading {target} where {symbol} is defined",
371
- tool_name="Read",
372
- tool_args={"file_path": target},
373
- tool_result=read_body,
374
- ),
375
- ],
376
- final_text=(
377
- f"`{symbol}` is defined in {target} (line {line}); it's a one-liner "
378
- f"that returns 'started'."
379
- ),
380
- chain_kind="read_grep_read",
381
- )
382
-
383
-
384
- def _bash_read_edit_row(rng: random.Random) -> _Row:
385
- """bash → read → edit. find a config file, open it, edit it."""
386
- name = rng.choice(["config.toml", "settings.json", "deploy.yaml"])
387
- where = rng.choice(_DIRS)
388
- found_path = f"./{where}/{name}"
389
- listing = f"{found_path}\n"
390
- if name.endswith(".toml"):
391
- old = "log_level = 'INFO'"
392
- new = "log_level = 'DEBUG'"
393
- ask = "set log_level to DEBUG"
394
- elif name.endswith(".json"):
395
- old = '"timeout": 30'
396
- new = '"timeout": 60'
397
- ask = "raise the timeout to 60"
398
- else:
399
- old = "replicas: 1"
400
- new = "replicas: 3"
401
- ask = "scale replicas to 3"
402
- read_body = f"# {name}\n{old}\n# (other config)\n"
403
- user = rng.choice([
404
- f"find {name} in the repo, read it, and {ask}",
405
- f"locate {name}, open it, then {ask}",
406
- f"where is {name}? open it and {ask}",
407
- ])
408
- return _Row(
409
- user_prompt=user,
410
- steps=[
411
- _Step(
412
- pre_text=f"finding {name}",
413
- tool_name="Bash",
414
- tool_args={"command": f"find . -name {name}"},
415
- tool_result=listing,
416
- ),
417
- _Step(
418
- pre_text=f"reading {found_path}",
419
- tool_name="Read",
420
- tool_args={"file_path": found_path},
421
- tool_result=read_body,
422
- ),
423
- _Step(
424
- pre_text="applying the edit",
425
- tool_name="Edit",
426
- tool_args={
427
- "file_path": found_path,
428
- "old_string": old,
429
- "new_string": new,
430
- },
431
- tool_result=f"applied edit to {found_path}.",
432
- ),
433
- ],
434
- final_text=f"updated {found_path}: {ask}.",
435
- chain_kind="bash_read_edit",
436
- )
437
-
438
-
439
- # ---- Registry --------------------------------------------------------------
440
-
441
-
442
- _GENERATORS: dict[str, tuple[Any, int]] = {
443
- # name: (generator_fn, default_n_rows)
444
- "bash_grep_read": (_bash_grep_read_row, 150),
445
- "bash_read": (_bash_read_row, 100),
446
- "grep_read": (_grep_read_row, 100),
447
- "read_edit": (_read_edit_row, 100),
448
- "read_bash": (_read_bash_row, 80),
449
- "read_grep_read": (_read_grep_read_row, 80),
450
- "bash_read_edit": (_bash_read_edit_row, 80),
451
- }
452
-
453
-
454
- # ---- Row builder -----------------------------------------------------------
455
-
456
-
457
- def _row_to_messages(r: _Row) -> dict[str, Any]:
458
- msgs: list[dict[str, Any]] = [{"role": "user", "content": r.user_prompt}]
459
- for step in r.steps:
460
- msgs.append({
461
- "role": "assistant",
462
- "content": step.pre_text,
463
- "tool_call": {"name": step.tool_name, "args": step.tool_args},
464
- })
465
- msgs.append({"role": "tool_result", "content": step.tool_result})
466
- msgs.append({"role": "assistant", "content": r.final_text})
467
- return {"messages": msgs, "_chain_kind": r.chain_kind}
468
-
469
-
470
- def generate_tool_chain_rows(
471
- seed: int = 42,
472
- n_per_chain: int = 100,
473
- chains: tuple[str, ...] | None = None,
474
- ) -> list[dict[str, Any]]:
475
- """Generate the tool-chain demo rows in memory.
476
-
477
- Deterministic per `seed`. Per-chain row counts:
478
- n_per_chain == 100 (default): use chain-specific defaults from
479
- _GENERATORS (sums to ~690).
480
- otherwise: use n_per_chain rows for EVERY chain (uniform).
481
- Pass `chains` to subset.
482
- """
483
- chain_keys = chains if chains is not None else tuple(_GENERATORS.keys())
484
- rng = random.Random(seed)
485
- out: list[dict[str, Any]] = []
486
- for chain_idx, chain in enumerate(chain_keys):
487
- gen, default_n = _GENERATORS[chain]
488
- n = default_n if n_per_chain == 100 else n_per_chain
489
- for i in range(n):
490
- row_rng = random.Random(seed * 7919 + chain_idx * 1_000_003 + i)
491
- row = gen(row_rng)
492
- out.append(_row_to_messages(row))
493
- rng.shuffle(out)
494
- return out
495
-
496
-
497
- # ---- SFT-side dataset class ------------------------------------------------
498
-
499
-
500
- class ToolChainDemoDataset:
501
- """In-memory dataset usable by `agentic_sft.py` task mixtures.
502
-
503
- Mirrors the JSONDataset / RoutingRecoveryDataset protocol.
504
- Drops the `_chain_kind` debug key before yielding messages.
505
- """
506
-
507
- def __init__(
508
- self,
509
- seed: int = 42,
510
- n_per_chain: int = 100,
511
- chains: tuple[str, ...] | None = None,
512
- **_: Any,
513
- ) -> None:
514
- self.ds = generate_tool_chain_rows(
515
- seed=seed, n_per_chain=n_per_chain, chains=chains,
516
- )
517
-
518
- def __len__(self) -> int:
519
- return len(self.ds)
520
-
521
- def __getitem__(self, idx: int) -> dict[str, Any]:
522
- messages = self.ds[idx]["messages"]
523
- return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + messages}
524
-
525
-
526
- # ---- CLI -------------------------------------------------------------------
527
-
528
-
529
- def _main() -> None:
530
- p = argparse.ArgumentParser()
531
- p.add_argument("--output", required=True, type=Path,
532
- help="Output JSONL path (one {messages:[...]} per line)")
533
- p.add_argument("--seed", type=int, default=42)
534
- p.add_argument("--n-per-chain", type=int, default=100,
535
- help="If 100: use chain-specific default counts (~690 total). "
536
- "Otherwise: use this count for every chain.")
537
- p.add_argument("--chains", type=str, default="",
538
- help="Comma-separated chain names; empty = all "
539
- "(bash_grep_read,bash_read,grep_read,read_edit,"
540
- "read_bash,read_grep_read,bash_read_edit)")
541
- args = p.parse_args()
542
-
543
- chains: tuple[str, ...] | None
544
- if args.chains:
545
- chains = tuple(c.strip() for c in args.chains.split(",") if c.strip())
546
- else:
547
- chains = None
548
- rows = generate_tool_chain_rows(
549
- seed=args.seed, n_per_chain=args.n_per_chain, chains=chains,
550
- )
551
- args.output.parent.mkdir(parents=True, exist_ok=True)
552
- with args.output.open("w", encoding="utf-8") as f:
553
- for row in rows:
554
- f.write(json.dumps(row, ensure_ascii=False) + "\n")
555
- summary = ", ".join(
556
- f"{c}={sum(1 for r in rows if r.get('_chain_kind') == c)}"
557
- for c in (chains or _GENERATORS.keys())
558
- )
559
- print(f"wrote {len(rows)} rows ({summary}) → {args.output}")
560
-
561
-
562
- if __name__ == "__main__":
563
- _main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/data/vrcpt_data.py DELETED
@@ -1,195 +0,0 @@
1
- """VR-CPT pretrain stream (verifier-mined case docs).
2
-
3
- Materializes a domain-aligned stream of REF.md §9.1 case documents produced by
4
- ``experiments/vrmining/miners/case_writer.py``. Source JSONL is normally at::
5
-
6
- experiments/vrmining/datasets/cpt_corpus.jsonl
7
-
8
- Each row has a ``text`` field containing a markdown case (~500-1500 token).
9
- 1k mining → 1000 cases ≈ 1M tokens. Used as a domain mixin alongside web/code
10
- in `agentic_pretrain.py --vrcpt-ratio` (see [[rec_4axis_pretrain]] Recipe A).
11
-
12
- Mirrors `nanoai.agentic.data.pcap_data` exactly — converts JSONL to parquet
13
- shards stored under ``$NANOAI_BASE_DIR/code_pretrain_data/vrcpt-cases/`` so
14
- the existing parquet-iter API drives it identically to web / code / pcap-corpus.
15
-
16
- Run once before pretraining:
17
-
18
- python -m nanoai.agentic.data.vrcpt_data prepare \\
19
- --src experiments/vrmining/datasets/cpt_corpus.jsonl \\
20
- --rows-per-shard 500
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import argparse
26
- import json
27
- import time
28
- from pathlib import Path
29
-
30
- import pyarrow as pa
31
- import pyarrow.parquet as pq
32
-
33
- from nanoai.common import print0
34
- from nanoai.agentic.data.pretrain_data import DATA_DIR
35
-
36
- DATASET_NAME = "vrcpt-cases"
37
- _TMP_SUFFIX = ".parquet.tmp"
38
-
39
-
40
- def vrcpt_corpus_dir() -> Path:
41
- return DATA_DIR / DATASET_NAME
42
-
43
-
44
- def _stream_jsonl_text(src: Path):
45
- with src.open("r", encoding="utf-8") as f:
46
- for line in f:
47
- line = line.strip()
48
- if not line:
49
- continue
50
- obj = json.loads(line)
51
- text = obj.get("text")
52
- if isinstance(text, str) and text:
53
- yield text
54
-
55
-
56
- def _try_open_shard(path: Path) -> bool:
57
- try:
58
- pf = pq.ParquetFile(path)
59
- return pf.num_row_groups > 0
60
- except Exception:
61
- return False
62
-
63
-
64
- def _validate_existing_corpus(out_dir: Path) -> tuple[bool, list[Path], str]:
65
- parquets = sorted(p for p in out_dir.iterdir() if p.suffix == ".parquet")
66
- tmps = [p for p in out_dir.iterdir() if p.name.endswith(_TMP_SUFFIX)]
67
- if tmps:
68
- return False, parquets, f"{len(tmps)} stale {_TMP_SUFFIX} file(s)"
69
- if len(parquets) < 2:
70
- return False, parquets, f"only {len(parquets)} shard(s); need ≥2"
71
- for p in parquets:
72
- if not _try_open_shard(p):
73
- return False, parquets, f"shard {p.name} unreadable"
74
- return True, parquets, "ok"
75
-
76
-
77
- def _quarantine_stale(out_dir: Path, reason_slug: str) -> Path | None:
78
- movable = [
79
- p for p in out_dir.iterdir()
80
- if p.suffix == ".parquet" or p.name.endswith(_TMP_SUFFIX)
81
- ]
82
- if not movable:
83
- return None
84
- attic = out_dir / f".attic_{int(time.time())}_{reason_slug}"
85
- attic.mkdir()
86
- for entry in movable:
87
- entry.rename(attic / entry.name)
88
- print0(
89
- f"[vrcpt_data] quarantined {len(movable)} stale file(s) to {attic.name}/"
90
- )
91
- return attic
92
-
93
-
94
- def _reason_slug(reason: str) -> str:
95
- return reason.split(";")[0].strip().replace(" ", "_")[:32] or "invalid"
96
-
97
-
98
- def prepare_from_jsonl(src: Path, rows_per_shard: int = 500):
99
- """One-shot JSONL → parquet shards under vrcpt_corpus_dir().
100
-
101
- Restart-safe and atomic-write (write .tmp then rename), same protocol as
102
- pcap_data. Last shard becomes val split (mirrors fineweb-edu convention).
103
- """
104
- out_dir = vrcpt_corpus_dir()
105
- out_dir.mkdir(parents=True, exist_ok=True)
106
-
107
- is_valid, existing, reason = _validate_existing_corpus(out_dir)
108
- if is_valid:
109
- print0(f"[vrcpt_data] {len(existing)} valid shards already in {out_dir}; skipping.")
110
- return existing
111
- if existing or any(p.name.endswith(_TMP_SUFFIX) for p in out_dir.iterdir()):
112
- print0(f"[vrcpt_data] existing corpus at {out_dir} INVALID ({reason}); rebuilding.")
113
- _quarantine_stale(out_dir, _reason_slug(reason))
114
-
115
- assert src.exists(), f"Source JSONL not found: {src}"
116
- print0(f"[vrcpt_data] reading {src} → {out_dir} ({rows_per_shard} rows/shard)")
117
-
118
- shard_idx = 0
119
- buf: list[str] = []
120
- written: list[Path] = []
121
- total_docs = 0
122
- total_chars = 0
123
-
124
- def flush():
125
- nonlocal shard_idx, buf
126
- if not buf:
127
- return
128
- shard_path = out_dir / f"shard_{shard_idx:05d}.parquet"
129
- tmp_path = shard_path.with_name(shard_path.name + ".tmp")
130
- shard_chars = sum(len(t) for t in buf)
131
- table = pa.table({"text": buf})
132
- pq.write_table(table, tmp_path, compression="snappy")
133
- tmp_path.rename(shard_path)
134
- written.append(shard_path)
135
- size_mb = shard_path.stat().st_size / 1e6
136
- print0(
137
- f"[vrcpt_data] wrote {shard_path.name} "
138
- f"({len(buf)} docs, {shard_chars/1e6:.2f}M chars, {size_mb:.2f} MB)"
139
- )
140
- shard_idx += 1
141
- buf = []
142
-
143
- for text in _stream_jsonl_text(src):
144
- buf.append(text)
145
- total_docs += 1
146
- total_chars += len(text)
147
- if len(buf) >= rows_per_shard:
148
- flush()
149
- flush()
150
-
151
- assert len(written) >= 2, (
152
- f"Only {len(written)} shard(s); need ≥2 (train/val). "
153
- f"Lower --rows-per-shard or supply a larger JSONL."
154
- )
155
- est_tokens = total_chars / 4
156
- print0(
157
- f"[vrcpt_data] done: {len(written)} shards (last is val), "
158
- f"{total_docs:,} docs, {total_chars/1e6:.2f}M chars, "
159
- f"≈{est_tokens/1e6:.2f}M tokens"
160
- )
161
- return written
162
-
163
-
164
- def list_vrcpt_parquet_files() -> list[Path]:
165
- d = vrcpt_corpus_dir()
166
- if not d.exists():
167
- return []
168
- return sorted([d / f for f in d.iterdir() if f.suffix == ".parquet"])
169
-
170
-
171
- def main():
172
- parser = argparse.ArgumentParser(description="Prepare vrcpt-cases parquet shards.")
173
- sub = parser.add_subparsers(dest="cmd", required=True)
174
- prep = sub.add_parser("prepare", help="convert cpt_corpus.jsonl to parquet shards")
175
- prep.add_argument("--src", type=Path, required=True,
176
- help="path to cpt_corpus.jsonl (case_writer output)")
177
- prep.add_argument("--rows-per-shard", type=int, default=500)
178
- sub.add_parser("list", help="list current vrcpt-cases shards")
179
-
180
- args = parser.parse_args()
181
- if args.cmd == "prepare":
182
- prepare_from_jsonl(args.src, rows_per_shard=args.rows_per_shard)
183
- elif args.cmd == "list":
184
- shards = list_vrcpt_parquet_files()
185
- if not shards:
186
- print(f"No shards in {vrcpt_corpus_dir()}")
187
- return
188
- total_bytes = sum(s.stat().st_size for s in shards)
189
- for s in shards:
190
- print(f" {s.name} ({s.stat().st_size / 1e6:.2f} MB)")
191
- print(f"total: {len(shards)} shards, {total_bytes / 1e6:.1f} MB")
192
-
193
-
194
- if __name__ == "__main__":
195
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/dense_verifier.py DELETED
@@ -1,247 +0,0 @@
1
- """Dense per-turn verifier reward + format penalty.
2
-
3
- Designed to break the GFT-TW mode-collapse trap:
4
- - Provides a non-zero signal per turn even when the group is at 100% pass,
5
- so training doesn't stall on saturated reward.
6
- - Penalises the specific degenerate outputs observed across iter-MCTS-DPO,
7
- GFT-TW, and AC v1: `assert assert assert ...`, mangled special tokens
8
- like `tool_call_end_end_end.log`, hallucinated tool_result content.
9
- - Independent of `task.check` — scores from the tool_result text and the
10
- decoded assistant content, so it works even on tasks the terminal grader
11
- is too lenient for.
12
-
13
- Wire-in points:
14
- - `score_turn(turn_dict)` runs after rollout, reads `tool_call`,
15
- `tool_result`, `error` fields from the Transcript turn dict.
16
- - `format_penalty(decoded_text)` runs over the assistant's text body.
17
- - `per_turn_dense_reward(rollout)` returns a list[float] aligned with
18
- `rollout.turn_spans`, ready to feed GAE.
19
- """
20
- from __future__ import annotations
21
-
22
- import re
23
- from dataclasses import dataclass
24
-
25
-
26
- # Reward magnitudes (tuned so format penalty > dense > shaping > terminal/N).
27
- EDIT_OK = 0.4
28
- EDIT_FAIL = -0.5
29
- READ_OK = 0.1
30
- READ_FAIL = -0.5
31
- BASH_OK = 0.1
32
- BASH_FAIL = -0.2
33
- GREP_HIT = 0.2
34
- GREP_MISS = -0.1
35
- PARSE_ERROR = -0.5
36
-
37
- # Format penalty magnitudes — large so they dominate any same-turn shaping.
38
- NGRAM_REPEAT_PENALTY = -1.0
39
- MANGLED_SPECIAL_PENALTY = -0.8
40
- TRUNCATED_TOOL_CALL_PENALTY = -0.6
41
-
42
- # Terminal grounding (kept small — dense reward carries most weight).
43
- TERMINAL_PASS = 0.5
44
- TERMINAL_FAIL = -0.3
45
-
46
-
47
- def score_turn(turn: dict) -> float:
48
- """Score a single assistant+tool_result turn pair from the Transcript.
49
-
50
- turn: a dict from `Transcript.turns` of the assistant role. The companion
51
- tool_result is read from `turn["_tool_result"]` (caller fills it in by
52
- pairing assistant + tool_result turns). If no tool_call is present (final
53
- answer turn), returns 0.0 — the terminal grounding handles those.
54
-
55
- Returns a float in roughly [-1, +1].
56
- """
57
- tc = turn.get("tool_call")
58
- if not tc:
59
- return 0.0
60
- name = tc.get("name", "")
61
- result_text = turn.get("_tool_result", "") or ""
62
- is_error = turn.get("_error", False)
63
-
64
- if name == "<parse_error>":
65
- return PARSE_ERROR
66
-
67
- lo = result_text.lower()
68
-
69
- if name == "Edit":
70
- # Distinguish a real edit from "old_string not found" / silent no-op.
71
- if is_error:
72
- return EDIT_FAIL
73
- if "successfully" in lo or "replaced" in lo or "wrote" in lo:
74
- return EDIT_OK
75
- if "not found" in lo or "no match" in lo:
76
- return EDIT_FAIL
77
- # Edit returned non-empty without an obvious success marker — small +.
78
- return EDIT_OK * 0.3
79
-
80
- if name == "Read":
81
- if is_error:
82
- return READ_FAIL
83
- if "file not found" in lo or "no such file" in lo or lo.startswith("error"):
84
- return READ_FAIL
85
- if len(result_text) > 20:
86
- return READ_OK
87
- # Empty / very short read result — neither rewarded nor punished much.
88
- return 0.0
89
-
90
- if name == "Bash":
91
- if is_error:
92
- return BASH_FAIL
93
- if "traceback" in lo or "modulenotfounderror" in lo or "no such file" in lo:
94
- return BASH_FAIL
95
- if "exit code 1" in lo or "command not found" in lo:
96
- return BASH_FAIL
97
- return BASH_OK
98
-
99
- if name == "Grep":
100
- if is_error:
101
- return GREP_MISS
102
- if not result_text.strip() or "no matches" in lo:
103
- return GREP_MISS
104
- return GREP_HIT
105
-
106
- # Unknown tool — neutral.
107
- return 0.0
108
-
109
-
110
- # Special-token leak patterns observed during mode collapse.
111
- _MANGLED_SPECIAL_PATTERNS = [
112
- re.compile(r"tool_call_end_end"),
113
- re.compile(r"tool_call_start.*tool_call_start.*tool_call_start"),
114
- re.compile(r"(<\|tool_[a-z_]+\|>){3,}"),
115
- re.compile(r"end_end_end"),
116
- ]
117
- _TOKEN_LIKE = re.compile(r"<\|[a-z_]+\|>")
118
-
119
-
120
- def format_penalty(text: str, window: int = 12) -> float:
121
- """Penalise degenerate output patterns observed in case-study.
122
-
123
- text: decoded assistant body for a single turn (string).
124
- window: how many tail tokens (words) to inspect for n-gram repeat.
125
-
126
- Returns a float in [-1.0, 0.0]. Multiple penalties are summed (clipped at -1.0).
127
- """
128
- if not text:
129
- return 0.0
130
- penalty = 0.0
131
-
132
- words = text.split()
133
- # 1) n-gram repetition: last `window` words all identical, OR
134
- # any single non-trivial word appears ≥3× in the window, OR
135
- # the most common 3-gram appears ≥ 3 times.
136
- if len(words) >= 6:
137
- tail = words[-window:] if len(words) >= window else words
138
- if len(set(tail[-5:])) == 1:
139
- penalty += NGRAM_REPEAT_PENALTY
140
- else:
141
- # Unigram repeat — catches `assertionassertio × 3` pattern.
142
- non_trivial = [w for w in tail if len(w) > 4]
143
- if non_trivial:
144
- max_unigram_count = max(non_trivial.count(w) for w in set(non_trivial))
145
- if max_unigram_count >= 3:
146
- penalty += NGRAM_REPEAT_PENALTY * 0.7
147
- # 3-gram repeat density check (catches `assert x; assert x; assert x`).
148
- ngrams = [tuple(tail[i:i + 3]) for i in range(len(tail) - 2)]
149
- if ngrams:
150
- most_common_count = max(ngrams.count(g) for g in set(ngrams))
151
- if most_common_count >= 3:
152
- penalty += NGRAM_REPEAT_PENALTY * 0.7
153
-
154
- # 2) Mangled / leaked special tokens in the visible text.
155
- for pat in _MANGLED_SPECIAL_PATTERNS:
156
- if pat.search(text):
157
- penalty += MANGLED_SPECIAL_PENALTY
158
- break
159
-
160
- # 3) Special-token leak: the SAME special token repeated ≥3 times,
161
- # e.g. `<|tool_call_start|><|tool_call_start|><|tool_call_start|>...`
162
- # (well-formed multi-arg tool call uses each tag at most twice).
163
- found = _TOKEN_LIKE.findall(text)
164
- if found:
165
- counts = {}
166
- for s in found:
167
- counts[s] = counts.get(s, 0) + 1
168
- if max(counts.values()) >= 3:
169
- penalty += TRUNCATED_TOOL_CALL_PENALTY
170
-
171
- # 4) Unclosed tool_call: has `<|tool_call_start|>` but no matching
172
- # `<|tool_call_end|>` (covers cases like
173
- # `<|tool_call_start|>Edit<|tool_arg|>...<|tool_val|>a` that escape
174
- # n-gram + special-token leak checks due to short total length).
175
- if "<|tool_call_start|>" in text and "<|tool_call_end|>" not in text:
176
- penalty += TRUNCATED_TOOL_CALL_PENALTY
177
-
178
- # 5) Unclosed tool_val: `<|tool_val|>` opens but text ends without further
179
- # structure (catches the edit_create_test PPO v1 regression where the
180
- # model emits `<|tool_val|>a` then stops with len < 10).
181
- if "<|tool_val|>" in text:
182
- after_val = text.rsplit("<|tool_val|>", 1)[-1]
183
- if len(after_val.strip()) < 5 and "<|tool_call_end|>" not in text:
184
- penalty += TRUNCATED_TOOL_CALL_PENALTY
185
-
186
- # 6) Brace explosion inside tool_val: model entered "write JSON" mode and
187
- # couldn't escape, producing unmatched runs of `{` or `}`. Catches the
188
- # edit_create_json failure mode where `<|tool_val|>{...{{{{` never reaches
189
- # `<|tool_call_end|>` because nested-brace prior overrides special-token escape.
190
- if "<|tool_val|>" in text and "<|tool_call_end|>" not in text:
191
- # Look at content after the LAST tool_val opener (the new_string body).
192
- body_after_val = text.rsplit("<|tool_val|>", 1)[-1]
193
- n_open = body_after_val.count("{")
194
- n_close = body_after_val.count("}")
195
- if n_open + n_close >= 8 and abs(n_open - n_close) >= 3:
196
- penalty += TRUNCATED_TOOL_CALL_PENALTY
197
- # Long run of identical brace chars (`}}}}}}}}` etc).
198
- if "}}}}}}" in body_after_val or "{{{{{{" in body_after_val:
199
- penalty += TRUNCATED_TOOL_CALL_PENALTY
200
-
201
- return max(penalty, -1.0)
202
-
203
-
204
- def per_turn_dense_reward(rollout) -> list[float]:
205
- """Compute dense per-turn reward aligned with rollout.turn_spans.
206
-
207
- Pairs each assistant turn with its tool_result (next entry in transcript)
208
- and computes score_turn + format_penalty for the assistant body.
209
- Terminal pass/fail grounding is appended to the LAST turn only.
210
- """
211
- transcript = rollout.transcript
212
- turns = transcript.turns
213
- # Build a list of (assistant_turn, paired_tool_result) for each TurnSpan.
214
- paired: list[tuple[dict, dict | None]] = []
215
- i = 0
216
- while i < len(turns):
217
- t = turns[i]
218
- if t.get("role") == "assistant":
219
- tr = None
220
- if i + 1 < len(turns) and turns[i + 1].get("role") == "tool_result":
221
- tr = turns[i + 1]
222
- i += 2
223
- else:
224
- i += 1
225
- paired.append((t, tr))
226
- else:
227
- i += 1
228
-
229
- rewards: list[float] = []
230
- for ts, (asst, tr) in zip(rollout.turn_spans, paired):
231
- # Fill in tool_result / error fields into the assistant dict for score_turn.
232
- a_dict = dict(asst)
233
- if tr is not None:
234
- a_dict["_tool_result"] = tr.get("tool_result", "")
235
- a_dict["_error"] = bool(tr.get("error", False))
236
- r_tool = score_turn(a_dict)
237
- r_fmt = format_penalty(asst.get("content", "") or "")
238
- rewards.append(r_tool + r_fmt)
239
-
240
- if rewards:
241
- # Terminal grounding on the last turn only.
242
- if getattr(rollout, "passed", False):
243
- rewards[-1] += TERMINAL_PASS
244
- else:
245
- rewards[-1] += TERMINAL_FAIL
246
-
247
- return rewards
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/diag/__init__.py DELETED
@@ -1,11 +0,0 @@
1
- """Agentic diagnosis micro-task eval package.
2
-
3
- This package defines the 8-task agentic diagnosis loop eval framework — the
4
- single source of truth for both ground-truth generation (capagent driver) and
5
- post-hoc grading (ksbr_eval extension).
6
-
7
- See plan: agentic diagnosis loop micro-task eval v1 (PCAP domain).
8
- """
9
-
10
- from .schemas import DIAG_TASKS, TaskSpec, FieldSpec # noqa: F401
11
- from .graders import GRADERS, grade_against_spec # noqa: F401
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/diag/chain_adapters.py DELETED
@@ -1,185 +0,0 @@
1
- """chain↔SFT schema adapters for the 8-task diagnosis loop.
2
-
3
- Why: per-task SFT was trained on shapes that don't always match what the 8-task
4
- chain runtime feeds. Without adaptation, chain inputs are OOD for some fields,
5
- which hurts per-task parse_ok rate and triggers task-identity collapse on long
6
- traces (see `evidence_summary_for_stop_controller` story).
7
-
8
- What's here:
9
- - hyp shape adapters (per downstream task) — v11 emits rich hyp dict; legacy
10
- SFT trained on simpler shapes. (Moved here from demo + webapp duplicates.)
11
- - evidence_summary_for_stop_controller — list[dict] → narrative string.
12
- - 4 new input-shape adapters resolving the high-severity mismatches found
13
- in 2026-05-22 schema audit:
14
- * packet_summary_to_str — wmb input (89% SFT trained on str)
15
- * time_window_to_dict — sd input (96% SFT trained on dict)
16
- * available_tools_to_dicts — tp input (88% SFT trained on list[dict])
17
- * list_or_dict_to_str — hu prediction/observation (84%/56% str)
18
-
19
- All adapters are idempotent — if input is already the SFT shape they pass it
20
- through unchanged."""
21
- from __future__ import annotations
22
-
23
- import json
24
- from typing import Any
25
-
26
-
27
- # ---- hyp shape adapters (v11 rich hyp → legacy per-task shapes) ----------------
28
-
29
- STATUS_TO_CONF: dict[str, float] = {
30
- "confirmed": 0.95, "provisionally_supported": 0.7,
31
- "open": 0.5, "unchanged": 0.5, "inconclusive": 0.5,
32
- "weakened": 0.3, "rejected": 0.4,
33
- }
34
- # 2026-05-26: rejected mapped 0.05→0.4 to fix universal-hu-rejection bug. v13
35
- # SFT trained hu to reject H1 in ~100% of chain runs even when H1 is truth.
36
- # Old 0.05 caused sc to see (rejected H1=0.05, open H2=0.5) and pick H2 (often
37
- # wrong direction). New 0.4 keeps rejection signal but doesn't collapse H1
38
- # below the open-prior of H2, letting sc consider H1 text content again.
39
- # Case study: d6 L2 v2 2/69 hits were text-overlap luck on rejected H1, not
40
- # real reasoning. d12 L2 v2 0/69 because d12 hu rejects more confidently.
41
-
42
-
43
- def hyp_for_test_planner(h: Any) -> dict:
44
- """test_planner SFT: hypotheses[i] = {id, description, confidence}."""
45
- if not isinstance(h, dict): return {}
46
- return {
47
- "id": h.get("id"),
48
- "description": h.get("claim", "")[:280],
49
- "confidence": STATUS_TO_CONF.get(h.get("status", "open"), 0.5),
50
- }
51
-
52
-
53
- def hyp_for_hyp_updater(h: Any) -> str:
54
- """hyp_updater SFT: hypothesis = plain string (the claim only)."""
55
- if not isinstance(h, dict): return ""
56
- return h.get("claim", "")
57
-
58
-
59
- def hyp_for_stop_controller(h: Any) -> dict:
60
- """stop_controller SFT: current_hypotheses[i] = {hypothesis, probability}."""
61
- if not isinstance(h, dict): return {}
62
- return {
63
- "hypothesis": h.get("claim", "")[:280],
64
- "probability": STATUS_TO_CONF.get(h.get("status", "open"), 0.5),
65
- }
66
-
67
-
68
- # ---- evidence summary adapter (today's stop_controller fix) --------------------
69
-
70
- def evidence_summary_for_stop_controller(evidence_so_far: list) -> str:
71
- """SFT trained stop_controller with `evidence_summary` as a narrative string
72
- (median ~300 chars). Chain accumulates list-of-dicts; without adaptation
73
- the model goes OOD by turn 9-10 and emits other tasks' schemas."""
74
- if not evidence_so_far:
75
- return "No evidence collected yet."
76
- parts: list[str] = []
77
- for e in evidence_so_far:
78
- if not isinstance(e, dict):
79
- continue
80
- turn = e.get("turn", "?")
81
- tool = e.get("tool", "?")
82
- obs_list = e.get("observations") or []
83
- if not isinstance(obs_list, list):
84
- obs_list = []
85
- obs_phrases: list[str] = []
86
- for o in obs_list[:4]:
87
- if isinstance(o, dict):
88
- bits = []
89
- for k in ("metric", "type", "value", "note", "details"):
90
- v = o.get(k)
91
- if isinstance(v, (str, int, float)) and v != "":
92
- bits.append(f"{k}={v}")
93
- if bits:
94
- obs_phrases.append("(" + ", ".join(bits[:3]) + ")")
95
- obs_str = "; ".join(obs_phrases) if obs_phrases else "no parsed observations"
96
- parts.append(f"turn {turn}: ran {tool}; {obs_str}.")
97
- return " ".join(parts)
98
-
99
-
100
- # ---- 4 new input-shape adapters (2026-05-22 audit fixes) -----------------------
101
-
102
- def packet_summary_to_str(ps: Any) -> str:
103
- """world_model_builder SFT: packet_summary = narrative string (89% of
104
- sampled training rows). Chain feeds aggregate dict from tshark. Adapter
105
- renders dict as a single-sentence narrative."""
106
- if isinstance(ps, str):
107
- return ps
108
- if not isinstance(ps, dict):
109
- return str(ps)
110
- parts = [f"Total {ps.get('total_packets', '?')} packets"]
111
- bits = []
112
- if ps.get("tcp_packets") is not None:
113
- bits.append(f"{ps['tcp_packets']} TCP")
114
- if ps.get("tls_packets"):
115
- bits.append(f"{ps['tls_packets']} TLS")
116
- if bits:
117
- parts.append(f" ({', '.join(bits)})")
118
- if ps.get("retransmissions"):
119
- parts.append(f"; {ps['retransmissions']} retransmissions")
120
- if ps.get("zero_window_advertisements"):
121
- parts.append(f", {ps['zero_window_advertisements']} zero-window advertisements")
122
- return "".join(parts) + "."
123
-
124
-
125
- def time_window_to_dict(tw: Any) -> dict:
126
- """symptom_detector SFT: time_window = dict{start, end} (96% of rows).
127
- Chain produces 'start_ts / end_ts' string. Split on ' / ' (or similar)
128
- and wrap as dict."""
129
- if isinstance(tw, dict):
130
- return tw
131
- if not isinstance(tw, str):
132
- return {"start": str(tw), "end": str(tw)}
133
- for sep in (" / ", "/", " -> ", " — ", "—"):
134
- if sep in tw:
135
- parts = [p.strip() for p in tw.split(sep, 1)]
136
- if len(parts) == 2:
137
- return {"start": parts[0], "end": parts[1]}
138
- return {"start": tw, "end": tw}
139
-
140
-
141
- def available_tools_to_dicts(tools: Any) -> list[dict]:
142
- """test_planner SFT: available_tools = list[dict] (88% of rows). Chain
143
- passes list[str] names. Wrap each string as {name: str}; pass dicts
144
- through."""
145
- if not isinstance(tools, list):
146
- return []
147
- out: list[dict] = []
148
- for t in tools:
149
- if isinstance(t, dict):
150
- out.append(t)
151
- elif isinstance(t, str):
152
- out.append({"name": t})
153
- return out
154
-
155
-
156
- def list_or_dict_to_str(x: Any, max_items: int = 3) -> str:
157
- """hypothesis_updater SFT: prediction and observation are plain strings
158
- (84% / 56% of rows). Chain feeds list[dict] (from test_planner
159
- expected_observations / observation_normalizer observations). Render as a
160
- short narrative; prefer description/type/value/metric/note keys; fall back
161
- to truncated JSON."""
162
- if x is None:
163
- return ""
164
- if isinstance(x, str):
165
- return x
166
- if isinstance(x, dict):
167
- return ", ".join(f"{k}={v}" for k, v in list(x.items())[:5])
168
- if isinstance(x, list):
169
- parts: list[str] = []
170
- key_priority = ("description", "type", "value", "metric", "note", "details")
171
- for item in x[:max_items]:
172
- if isinstance(item, dict):
173
- bits: list[str] = []
174
- for k in key_priority:
175
- if k in item:
176
- bits.append(f"{k}={item[k]}")
177
- if len(bits) >= 3:
178
- break
179
- if not bits:
180
- bits.append(json.dumps(item, ensure_ascii=False)[:80])
181
- parts.append("; ".join(bits))
182
- else:
183
- parts.append(str(item)[:80])
184
- return " | ".join(parts) if parts else ""
185
- return str(x)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/diag/graders.py DELETED
@@ -1,271 +0,0 @@
1
- """Graders for the 8 agentic diagnosis micro-tasks.
2
-
3
- Design contract: every grader returns
4
- (passed: bool, detail: dict)
5
-
6
- where `detail` MUST include:
7
- format_ok bool — JSON parsed AND required top-level keys present
8
- content_ok bool — all field-level content checks passed
9
- field_scores dict — per-field {ok, reason} breakdown (for case study)
10
- completion_head str — first 120 chars of completion (for triage)
11
-
12
- `passed = format_ok and content_ok`. Reporting separates the two so that a
13
- model failing to produce JSON (format_ok=0) is distinguished from one that
14
- produces JSON with wrong content (format_ok=high, content_ok=low). Conflating
15
- these was the failure mode of agent_eval_mt 38: a single binary pass/fail
16
- hid which agent rung was actually broken.
17
-
18
- Lenience policy (anti-Goodhart, [[lesson-r-arith-grader-too-lenient]]):
19
- * structural keys + enum values: STRICT (exact match required)
20
- * free-text fields: LOW bar — Jaccard ≥0.15 OR length ≥10 chars suffices
21
- * list lengths: target within ±100% of expected (just "non-empty" if min_items==1)
22
- * the goal: a model that produces sensible JSON in the right shape scores
23
- 40-70% on content_ok; pure garbage scores <10%
24
- """
25
- from __future__ import annotations
26
-
27
- import json
28
- import re
29
- from typing import Any
30
-
31
- from .schemas import DIAG_TASKS, TaskSpec, FieldSpec
32
-
33
-
34
- # ---------- helpers ----------
35
-
36
- _JSON_OBJ_RE = re.compile(r"\{[\s\S]*\}")
37
- _TOKEN_RE = re.compile(r"\w+", re.UNICODE)
38
-
39
-
40
- def _extract_json(completion: str) -> dict | None:
41
- """Find the first top-level JSON object in completion and parse it.
42
-
43
- Tolerates leading/trailing prose, markdown code fences, etc. Greedy match
44
- on outermost braces; if that fails, return None.
45
- """
46
- if not completion:
47
- return None
48
- # Strip markdown code fences ```json ... ```
49
- t = completion.strip()
50
- if t.startswith("```"):
51
- t = re.sub(r"^```(?:json)?\s*", "", t)
52
- t = re.sub(r"\s*```$", "", t)
53
- m = _JSON_OBJ_RE.search(t)
54
- if not m:
55
- return None
56
- try:
57
- return json.loads(m.group(0))
58
- except json.JSONDecodeError:
59
- # Try a more permissive fallback: balance braces manually.
60
- try:
61
- depth = 0
62
- start = t.find("{")
63
- if start < 0:
64
- return None
65
- for i, ch in enumerate(t[start:], start=start):
66
- if ch == "{":
67
- depth += 1
68
- elif ch == "}":
69
- depth -= 1
70
- if depth == 0:
71
- return json.loads(t[start : i + 1])
72
- return None
73
- except Exception:
74
- return None
75
-
76
-
77
- def _tokens(s: str) -> set[str]:
78
- if not isinstance(s, str):
79
- return set()
80
- return set(_TOKEN_RE.findall(s.lower()))
81
-
82
-
83
- def _jaccard(a: str, b: str) -> float:
84
- ta, tb = _tokens(a), _tokens(b)
85
- if not ta and not tb:
86
- return 1.0
87
- if not ta or not tb:
88
- return 0.0
89
- return len(ta & tb) / len(ta | tb)
90
-
91
-
92
- # ---------- per-field checks ----------
93
-
94
- def _check_field(
95
- field: FieldSpec, predicted: Any, expected: Any
96
- ) -> tuple[bool, bool, str]:
97
- """Return (format_ok_field, content_ok_field, reason).
98
-
99
- format_ok_field = whether the type/shape constraints are met
100
- content_ok_field = whether content matches (or is reasonably close to) expected
101
- reason = short string for case study
102
- """
103
- if predicted is None:
104
- return False, False, "missing"
105
-
106
- # Kind-specific shape check
107
- if field.kind == "str":
108
- if not isinstance(predicted, str):
109
- return False, False, f"not_str:{type(predicted).__name__}"
110
- if not predicted.strip():
111
- return True, False, "empty"
112
- if field.freetext:
113
- # low-bar content check: Jaccard ≥0.15 OR ≥10 chars present
114
- j = _jaccard(predicted, expected if isinstance(expected, str) else "")
115
- length_ok = len(predicted) >= 10
116
- content = j >= 0.15 or length_ok
117
- return True, content, f"jaccard={j:.2f}_len={len(predicted)}"
118
- else:
119
- # exact match required for non-freetext str
120
- content = predicted.strip() == (expected.strip() if isinstance(expected, str) else "")
121
- return True, content, "exact" if content else f"mismatch:{predicted[:40]!r}"
122
-
123
- if field.kind == "int":
124
- if not isinstance(predicted, int) or isinstance(predicted, bool):
125
- return False, False, f"not_int:{type(predicted).__name__}"
126
- content = predicted == expected if isinstance(expected, int) else True
127
- return True, content, "ok"
128
-
129
- if field.kind == "float":
130
- if not isinstance(predicted, (int, float)) or isinstance(predicted, bool):
131
- return False, False, f"not_num:{type(predicted).__name__}"
132
- if isinstance(expected, (int, float)):
133
- content = abs(predicted - expected) < 1e-6
134
- else:
135
- content = True
136
- return True, content, "ok"
137
-
138
- if field.kind == "enum":
139
- if not isinstance(predicted, str):
140
- return False, False, f"not_str:{type(predicted).__name__}"
141
- allowed = field.enum_values or []
142
- if predicted not in allowed:
143
- return False, False, f"not_in_enum:{predicted!r}"
144
- # content check: matches expected if provided
145
- content = (predicted == expected) if isinstance(expected, str) else True
146
- return True, content, ("match" if content else f"wrong_enum:{predicted!r}")
147
-
148
- if field.kind == "list":
149
- if not isinstance(predicted, list):
150
- return False, False, f"not_list:{type(predicted).__name__}"
151
- if len(predicted) < field.min_items:
152
- return False, False, f"too_few:{len(predicted)}<{field.min_items}"
153
- # content: count within ±100% of expected length (very generous)
154
- if isinstance(expected, list) and expected:
155
- ratio = len(predicted) / len(expected)
156
- content = 0.5 <= ratio <= 2.0
157
- else:
158
- content = True
159
- return True, content, f"len={len(predicted)}"
160
-
161
- if field.kind == "list_obj":
162
- if not isinstance(predicted, list):
163
- return False, False, f"not_list:{type(predicted).__name__}"
164
- if len(predicted) < field.min_items:
165
- return False, False, f"too_few:{len(predicted)}<{field.min_items}"
166
- # every item must be a dict with the required keys
167
- required = field.item_keys or []
168
- for i, item in enumerate(predicted):
169
- if not isinstance(item, dict):
170
- return False, False, f"item{i}_not_dict"
171
- missing = [k for k in required if k not in item]
172
- if missing:
173
- return False, False, f"item{i}_missing:{missing}"
174
- # enum check on per-item enum-constrained fields
175
- if field.item_enums:
176
- for key, allowed in field.item_enums.items():
177
- if key in item and item[key] not in allowed:
178
- return False, False, f"item{i}.{key}_not_in_enum:{item[key]!r}"
179
- # content: length within ±100% of expected if expected is a list
180
- if isinstance(expected, list) and expected:
181
- ratio = len(predicted) / len(expected)
182
- content = 0.5 <= ratio <= 2.0
183
- else:
184
- content = True
185
- return True, content, f"len={len(predicted)}"
186
-
187
- if field.kind == "obj":
188
- if not isinstance(predicted, dict):
189
- return False, False, f"not_dict:{type(predicted).__name__}"
190
- if not predicted:
191
- return True, False, "empty_dict"
192
- # content: ≥3 keys present (loose) and at least 1 overlapping key with expected
193
- if isinstance(expected, dict) and expected:
194
- overlap = set(predicted.keys()) & set(expected.keys())
195
- content = len(overlap) >= 1
196
- else:
197
- content = len(predicted) >= 1
198
- return True, content, f"keys={sorted(predicted.keys())[:5]}"
199
-
200
- return False, False, f"unknown_kind:{field.kind}"
201
-
202
-
203
- # ---------- generic spec-based grader ----------
204
-
205
- def grade_against_spec(
206
- spec: TaskSpec, expected: dict, completion: str
207
- ) -> tuple[bool, dict]:
208
- """Generic grader: validate `completion` JSON against `spec` and compare to `expected`."""
209
- head = (completion or "")[:120]
210
-
211
- parsed = _extract_json(completion)
212
- if parsed is None:
213
- return False, {
214
- "format_ok": False,
215
- "content_ok": False,
216
- "field_scores": {},
217
- "completion_head": head,
218
- "reason": "no_json",
219
- }
220
-
221
- field_scores: dict[str, dict] = {}
222
- format_ok_all = True
223
- content_ok_all = True
224
-
225
- # Check required top-level keys present
226
- missing_top = [f.name for f in spec.output_fields
227
- if f.required and f.name not in parsed]
228
- if missing_top:
229
- return False, {
230
- "format_ok": False,
231
- "content_ok": False,
232
- "field_scores": {k: {"ok": False, "reason": "missing_top_key"} for k in missing_top},
233
- "completion_head": head,
234
- "reason": f"missing_top:{missing_top}",
235
- }
236
-
237
- # Per-field check
238
- for f in spec.output_fields:
239
- predicted_val = parsed.get(f.name)
240
- expected_val = expected.get(f.name) if isinstance(expected, dict) else None
241
- f_format, f_content, reason = _check_field(f, predicted_val, expected_val)
242
- field_scores[f.name] = {"ok": f_format and f_content, "format": f_format,
243
- "content": f_content, "reason": reason}
244
- if not f_format:
245
- format_ok_all = False
246
- if not f_content:
247
- content_ok_all = False
248
-
249
- return format_ok_all and content_ok_all, {
250
- "format_ok": format_ok_all,
251
- "content_ok": content_ok_all,
252
- "field_scores": field_scores,
253
- "completion_head": head,
254
- }
255
-
256
-
257
- # ---------- 8 thin wrappers (registered into GRADERS) ----------
258
-
259
- def _make_grader(task_id: str):
260
- spec = DIAG_TASKS[task_id]
261
-
262
- def grader(row: dict, completion: str) -> tuple[bool, dict]:
263
- expected = row.get("expected_output", {}) or {}
264
- return grade_against_spec(spec, expected, completion)
265
- grader.__name__ = f"grade_{task_id}"
266
- return grader
267
-
268
-
269
- GRADERS: dict[str, Any] = {
270
- f"diag_{tid}": _make_grader(tid) for tid in DIAG_TASKS.keys()
271
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/diag/graders_v2.py DELETED
@@ -1,651 +0,0 @@
1
- """Strict semantic graders for diag_eval_v1 — v2 of graders.py.
2
-
3
- Motivation: v1 graders (graders.py) are SHAPE-graders. The
4
- `experiments/ksbr_pcap/scripts/fake_shape_baseline.py` audit (2026-05-21) showed shape-correct
5
- gibberish achieves 99-100% pass on 3 tasks (hypothesis_updater, hypothesis_generator,
6
- symptom_detector) and 43.4% overall. That makes v1 numbers a JSON-schema-
7
- compliance score, not a diagnostic-capability score.
8
-
9
- v2 adds task-specific semantic checks that cross-reference the model's
10
- predicted content against the row's input_state. Each task has a
11
- hand-tuned check function. Default: STRICT — passing means the model's
12
- output has structure AND content tied to the input.
13
-
14
- Result contract is identical to v1 (passed, detail with format_ok /
15
- content_ok / field_scores / completion_head). v1 GRADERS are unaffected.
16
-
17
- Registered as `diag_<task_id>_v2`. Use `scripts/regrade_jsonl.py` to
18
- re-grade existing v1 result jsonl with v2 graders without re-running eval.
19
- """
20
- from __future__ import annotations
21
-
22
- import json
23
- import re
24
- from typing import Any
25
-
26
- from .schemas import DIAG_TASKS, TaskSpec, FieldSpec
27
- from .graders import _extract_json, _tokens, _jaccard
28
-
29
- # ---------- helpers ----------
30
-
31
- _IPV4_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?$")
32
- _IPV6_RE = re.compile(r"^[0-9a-fA-F:]+(?::\d+)?$")
33
- _FLOW_TUPLE_RE = re.compile(
34
- r"^\s*[0-9a-fA-F\.:\-_]+(?::\d+)?\s*(?:->|→|<-)\s*[0-9a-fA-F\.:\-_]+(?::\d+)?\s*$"
35
- )
36
- _DIGIT_RE = re.compile(r"^\d+$")
37
- _FAKE_FILLER_RE = re.compile(r"^(x+|y+|foo|bar|baz|test|xxxx+|placeholder)$", re.IGNORECASE)
38
-
39
- # Real-ish PCAP tool/check names. Substring-matched to be tolerant of
40
- # variation like "tshark_filter" vs "tshark.filter".
41
- _PCAP_TOOL_TOKENS = {
42
- "tshark", "tcpdump", "wireshark", "zeek", "bro", "scapy",
43
- "ping", "traceroute", "tracert", "mtr", "nmap", "dig", "nslookup", "host",
44
- "ss", "netstat", "ip", "tc", "ethtool", "iptables", "ipset", "nft",
45
- "iperf", "iperf3", "curl", "wget", "openssl",
46
- "syn_probe", "syn_scan", "tcp_probe", "tcp_retransmit", "tcp_window",
47
- "mtu_probe", "path_mtu", "dns_query", "dns_check", "icmp_ping",
48
- "tcp_stream", "flow_extract", "filter", "capture",
49
- "kernel_log", "dmesg", "journalctl", "sar", "vmstat", "iostat",
50
- "cpu_usage", "memory_check", "disk_io", "load_average",
51
- "ipsec", "ike", "strongswan", "openvpn", "wg",
52
- "stat", "log", "metric",
53
- }
54
-
55
- _PROTOCOL_TOKENS = {
56
- "TCP", "UDP", "ICMP", "ICMPv6", "GRE", "ESP", "AH", "SCTP", "QUIC",
57
- "TLS", "DTLS", "SSH", "HTTP", "HTTPS", "HTTP/2", "HTTP/3", "SMTP",
58
- "DNS", "DHCP", "BGP", "OSPF", "ARP", "NDP", "MPTCP", "MPLS", "VXLAN",
59
- "WireGuard", "IPSec", "L2TP", "GTP", "SIP", "RTP", "RTCP", "MQTT",
60
- }
61
-
62
-
63
- def _is_filler(s: Any) -> bool:
64
- """Return True if s is fake placeholder text like 'xxxx' / 'foo' / empty.
65
-
66
- Numeric strings (e.g. "1", "10", "12345") and short IDs are NOT filler.
67
- Repetitive single-char fillers (xxxx, aaaa) ARE.
68
- """
69
- if not isinstance(s, str):
70
- return False
71
- t = s.strip()
72
- if not t:
73
- return True
74
- # Explicit fake words
75
- if _FAKE_FILLER_RE.match(t):
76
- return True
77
- # Pure digits or hex chars are valid identifiers (packet_ref, IDs, etc)
78
- if t.isdigit() or all(c in "0123456789abcdefABCDEF:.-_" for c in t):
79
- return False
80
- # Repetitive single-char strings ≥ 4 chars (xxxx, aaaa, ----)
81
- if len(t) >= 4 and len(set(t)) <= 2:
82
- return True
83
- return False
84
-
85
-
86
- def _has_pcap_tool_token(s: str) -> bool:
87
- if not isinstance(s, str):
88
- return False
89
- sl = s.lower()
90
- return any(tok in sl for tok in _PCAP_TOOL_TOKENS)
91
-
92
-
93
- def _is_ip(s: str) -> bool:
94
- if not isinstance(s, str):
95
- return False
96
- s = s.strip()
97
- if _IPV4_RE.match(s):
98
- # validate octets
99
- host = s.split(":")[0]
100
- try:
101
- return all(0 <= int(o) <= 255 for o in host.split("."))
102
- except Exception:
103
- return False
104
- if _IPV6_RE.match(s) and "." not in s:
105
- return ":" in s and any(c in "0123456789abcdefABCDEF" for c in s)
106
- return False
107
-
108
-
109
- def _collect_ips_from_input_state(input_state: dict) -> set[str]:
110
- """Pull plausible IP strings from anywhere in input_state."""
111
- out: set[str] = set()
112
-
113
- def _walk(obj):
114
- if isinstance(obj, str):
115
- for tok in re.findall(r"\d{1,3}(?:\.\d{1,3}){3}", obj):
116
- if _is_ip(tok):
117
- out.add(tok)
118
- elif isinstance(obj, dict):
119
- for v in obj.values():
120
- _walk(v)
121
- elif isinstance(obj, list):
122
- for v in obj:
123
- _walk(v)
124
- _walk(input_state)
125
- return out
126
-
127
-
128
- def _input_state_text(input_state: dict) -> str:
129
- """Flatten input_state to a searchable text blob for cross-checks."""
130
- try:
131
- return json.dumps(input_state, ensure_ascii=False).lower()
132
- except Exception:
133
- return str(input_state).lower()
134
-
135
-
136
- def _result(format_ok: bool, content_ok: bool, field_scores: dict,
137
- completion_head: str, reason: str = "") -> tuple[bool, dict]:
138
- d = {
139
- "format_ok": format_ok,
140
- "content_ok": content_ok,
141
- "field_scores": field_scores,
142
- "completion_head": completion_head,
143
- "grader_version": "v2",
144
- }
145
- if reason:
146
- d["reason"] = reason
147
- return format_ok and content_ok, d
148
-
149
-
150
- def _parse(completion: str) -> tuple[dict | None, str]:
151
- head = (completion or "")[:120]
152
- return _extract_json(completion), head
153
-
154
-
155
- # ---------- per-task semantic graders ----------
156
-
157
- def grade_problem_framing_v2(row: dict, completion: str) -> tuple[bool, dict]:
158
- """Strict: scope must have ≥2 real keys; goal/question must overlap with input request."""
159
- input_state = row.get("input_state", {}) or {}
160
- parsed, head = _parse(completion)
161
- if parsed is None:
162
- return _result(False, False, {}, head, "no_json")
163
-
164
- required = ["diagnosis_goal", "scope", "primary_question", "constraints", "initial_uncertainties"]
165
- missing = [k for k in required if k not in parsed]
166
- if missing:
167
- return _result(False, False,
168
- {k: {"ok": False, "reason": "missing_top_key"} for k in missing},
169
- head, f"missing_top:{missing}")
170
-
171
- user_req = input_state.get("user_request", "")
172
- user_tokens = _tokens(user_req)
173
- fs = {}
174
-
175
- # diagnosis_goal: must be str ≥ 30 chars + overlap with input topic
176
- goal = parsed.get("diagnosis_goal", "")
177
- goal_ok = (isinstance(goal, str) and len(goal) >= 30
178
- and not _is_filler(goal)
179
- and (not user_tokens or _jaccard(goal, user_req) >= 0.10))
180
- fs["diagnosis_goal"] = {"ok": goal_ok, "format": isinstance(goal, str),
181
- "content": goal_ok, "reason": f"len={len(goal) if isinstance(goal, str) else 0} jaccard={_jaccard(goal, user_req):.2f}"}
182
-
183
- # scope: dict with ≥2 keys, must include a scope-like key
184
- scope = parsed.get("scope", {})
185
- scope_keys = set(scope.keys()) if isinstance(scope, dict) else set()
186
- scope_topic_keys = {
187
- "protocol_layer", "protocol_layers", "protocols",
188
- "time_window", "duration", "timeframe",
189
- "traffic_direction", "direction",
190
- "focus_area", "focus", "scope_boundaries", "boundaries",
191
- "endpoints", "ip_range", "host_filter",
192
- }
193
- has_topic = bool(scope_keys & scope_topic_keys)
194
- scope_ok = (isinstance(scope, dict) and len(scope_keys) >= 2 and has_topic)
195
- fs["scope"] = {"ok": scope_ok, "format": isinstance(scope, dict),
196
- "content": has_topic, "reason": f"keys={sorted(scope_keys)[:6]}"}
197
-
198
- # primary_question: str ≥ 20 chars; should be question-like (contain '?' or 'what'/'why'/'how'/etc)
199
- pq = parsed.get("primary_question", "")
200
- q_tokens = {"what", "why", "how", "when", "where", "which", "?"}
201
- pq_ok = (isinstance(pq, str) and len(pq) >= 20 and not _is_filler(pq)
202
- and (any(t in pq.lower() for t in q_tokens) or "?" in pq))
203
- fs["primary_question"] = {"ok": pq_ok, "format": isinstance(pq, str),
204
- "content": pq_ok, "reason": f"len={len(pq) if isinstance(pq, str) else 0}"}
205
-
206
- # constraints & uncertainties: lists of substantive strings
207
- for k in ["constraints", "initial_uncertainties"]:
208
- lst = parsed.get(k, [])
209
- if not isinstance(lst, list) or len(lst) == 0:
210
- fs[k] = {"ok": False, "format": False, "content": False, "reason": "not_list_or_empty"}
211
- continue
212
- substantive = [x for x in lst if isinstance(x, str)
213
- and len(x) >= 20 and not _is_filler(x)]
214
- ok = len(substantive) >= 1
215
- fs[k] = {"ok": ok, "format": True, "content": ok,
216
- "reason": f"substantive={len(substantive)}/{len(lst)}"}
217
-
218
- format_ok = all(v.get("format", False) for v in fs.values())
219
- content_ok = all(v.get("content", False) for v in fs.values())
220
- return _result(format_ok, content_ok, fs, head)
221
-
222
-
223
- def grade_world_model_builder_v2(row: dict, completion: str) -> tuple[bool, dict]:
224
- """Strict: entities must have valid IPs; flows must have valid tuple format; IPs overlap with input."""
225
- input_state = row.get("input_state", {}) or {}
226
- parsed, head = _parse(completion)
227
- if parsed is None:
228
- return _result(False, False, {}, head, "no_json")
229
-
230
- required = ["entities", "flows", "capture_assumptions", "world_model_risks"]
231
- missing = [k for k in required if k not in parsed]
232
- if missing:
233
- return _result(False, False,
234
- {k: {"ok": False, "reason": "missing_top_key"} for k in missing},
235
- head, f"missing_top:{missing}")
236
-
237
- input_ips = _collect_ips_from_input_state(input_state)
238
- fs = {}
239
-
240
- # entities: list of dicts with valid IP + type enum
241
- ents = parsed.get("entities", [])
242
- if not isinstance(ents, list) or len(ents) < 2:
243
- fs["entities"] = {"ok": False, "format": False, "content": False, "reason": "not_list_or_too_few"}
244
- else:
245
- good = []
246
- bad = []
247
- for i, e in enumerate(ents):
248
- if not isinstance(e, dict):
249
- bad.append(f"item{i}_not_dict")
250
- continue
251
- ip = e.get("ip", "")
252
- etype = e.get("type", "")
253
- if not _is_ip(ip):
254
- bad.append(f"item{i}_bad_ip:{ip}")
255
- continue
256
- if etype not in {"client", "server", "intermediate", "unknown", "router", "gateway", "proxy", "loadbalancer"}:
257
- bad.append(f"item{i}_bad_type:{etype}")
258
- continue
259
- good.append(ip)
260
- # at least 2 good entities; if input has known IPs, at least 1 must overlap
261
- n_overlap = len(set(good) & input_ips) if input_ips else 0
262
- ok = (len(good) >= 2 and (not input_ips or n_overlap >= 1))
263
- fs["entities"] = {"ok": ok, "format": len(good) >= 2,
264
- "content": (not input_ips or n_overlap >= 1),
265
- "reason": f"good={len(good)} overlap={n_overlap}/{len(input_ips)} bad={bad[:3]}"}
266
-
267
- # flows: list_obj with tuple as EITHER "IP:port -> IP:port" string OR dict with src/dst keys
268
- flows = parsed.get("flows", [])
269
- if not isinstance(flows, list) or len(flows) < 1:
270
- fs["flows"] = {"ok": False, "format": False, "content": False, "reason": "not_list_or_empty"}
271
- else:
272
- ok_count = 0
273
- for f in flows:
274
- if not isinstance(f, dict):
275
- continue
276
- t = f.get("tuple")
277
- p = f.get("protocol", "")
278
- t_ok = False
279
- if isinstance(t, str) and _FLOW_TUPLE_RE.match(t.strip()):
280
- t_ok = True
281
- elif isinstance(t, dict):
282
- # dict form: must have src_ip/dst_ip (or equivalent)
283
- src_keys = {"src_ip", "source_ip", "src", "source"}
284
- dst_keys = {"dst_ip", "destination_ip", "dst", "destination"}
285
- has_src = any(k in t for k in src_keys)
286
- has_dst = any(k in t for k in dst_keys)
287
- t_ok = has_src and has_dst
288
- if t_ok and isinstance(p, str) and len(p) >= 2:
289
- ok_count += 1
290
- ok = ok_count >= 1
291
- fs["flows"] = {"ok": ok, "format": ok, "content": ok,
292
- "reason": f"valid_tuple_protocol={ok_count}/{len(flows)}"}
293
-
294
- # capture_assumptions: list of substantive strings
295
- ca = parsed.get("capture_assumptions", [])
296
- sub = [x for x in (ca if isinstance(ca, list) else [])
297
- if isinstance(x, str) and len(x) >= 30 and not _is_filler(x)]
298
- fs["capture_assumptions"] = {"ok": len(sub) >= 1, "format": isinstance(ca, list),
299
- "content": len(sub) >= 1,
300
- "reason": f"substantive={len(sub)}"}
301
-
302
- # world_model_risks: list of substantive strings
303
- wmr = parsed.get("world_model_risks", [])
304
- sub = [x for x in (wmr if isinstance(wmr, list) else [])
305
- if isinstance(x, str) and len(x) >= 20 and not _is_filler(x)]
306
- fs["world_model_risks"] = {"ok": len(sub) >= 1, "format": isinstance(wmr, list),
307
- "content": len(sub) >= 1,
308
- "reason": f"substantive={len(sub)}"}
309
-
310
- format_ok = all(v.get("format", False) for v in fs.values())
311
- content_ok = all(v.get("content", False) for v in fs.values())
312
- return _result(format_ok, content_ok, fs, head)
313
-
314
-
315
- def grade_symptom_detector_v2(row: dict, completion: str) -> tuple[bool, dict]:
316
- """Strict: symptoms must have valid enum types AND evidence_refs grounded in input."""
317
- input_state = row.get("input_state", {}) or {}
318
- parsed, head = _parse(completion)
319
- if parsed is None:
320
- return _result(False, False, {}, head, "no_json")
321
-
322
- if "symptoms" not in parsed:
323
- return _result(False, False, {"symptoms": {"ok": False, "reason": "missing"}},
324
- head, "missing_top")
325
-
326
- symptoms = parsed.get("symptoms", [])
327
- if not isinstance(symptoms, list) or len(symptoms) < 1:
328
- return _result(False, False, {"symptoms": {"ok": False, "reason": "not_list"}},
329
- head, "not_list")
330
-
331
- allowed_types = {
332
- "tcp_retransmission_burst", "tcp_duplicate_ack", "tcp_zero_window",
333
- "tcp_reset", "server_response_gap", "client_idle_gap",
334
- "tls_handshake_delay", "dns_lookup_delay", "dns_no_response",
335
- "rtt_spike", "high_jitter", "mtu_blackhole",
336
- }
337
- sev_allowed = {"low", "medium", "high", "critical"}
338
- input_text = _input_state_text(input_state)
339
-
340
- ok_count = 0
341
- bad = []
342
- for i, s in enumerate(symptoms):
343
- if not isinstance(s, dict):
344
- bad.append(f"item{i}_not_dict")
345
- continue
346
- if s.get("type") not in allowed_types:
347
- bad.append(f"item{i}_bad_type:{s.get('type')}")
348
- continue
349
- if s.get("severity") not in sev_allowed:
350
- bad.append(f"item{i}_bad_severity")
351
- continue
352
- # evidence_refs must be non-empty list of substantive strings
353
- refs = s.get("evidence_refs", [])
354
- if not isinstance(refs, list) or len(refs) == 0:
355
- bad.append(f"item{i}_no_refs")
356
- continue
357
- sub_refs = [r for r in refs if isinstance(r, str) and len(r) >= 4 and not _is_filler(r)]
358
- if len(sub_refs) == 0:
359
- bad.append(f"item{i}_filler_refs")
360
- continue
361
- # grounding: at least one ref-token must appear in input tokens.
362
- # (Jaccard was unfair when refs is small {1-3 tokens} vs input {200+}.)
363
- from .graders import _tokens as _tk
364
- refs_joined = " ".join(sub_refs)
365
- ref_tokens = _tk(refs_joined)
366
- input_tokens = _tk(input_text)
367
- if ref_tokens and not (ref_tokens & input_tokens):
368
- bad.append(f"item{i}_ungrounded_refs")
369
- continue
370
- ok_count += 1
371
- ok = ok_count >= 1
372
- fs = {"symptoms": {"ok": ok, "format": ok_count >= 1, "content": ok_count >= 1,
373
- "reason": f"valid={ok_count}/{len(symptoms)} bad={bad[:3]}"}}
374
- return _result(ok, ok, fs, head)
375
-
376
-
377
- def grade_hypothesis_generator_v2(row: dict, completion: str) -> tuple[bool, dict]:
378
- """Strict: ≥2 substantive hypotheses with grounded claim + falsifiable tests."""
379
- input_state = row.get("input_state", {}) or {}
380
- parsed, head = _parse(completion)
381
- if parsed is None:
382
- return _result(False, False, {}, head, "no_json")
383
-
384
- if "hypotheses" not in parsed:
385
- return _result(False, False, {"hypotheses": {"ok": False, "reason": "missing"}},
386
- head, "missing_top")
387
-
388
- hyps = parsed.get("hypotheses", [])
389
- if not isinstance(hyps, list) or len(hyps) < 2:
390
- return _result(False, False,
391
- {"hypotheses": {"ok": False, "format": False, "content": False,
392
- "reason": f"only_{len(hyps) if isinstance(hyps, list) else 0}"}},
393
- head, "too_few_hyps")
394
-
395
- input_text = _input_state_text(input_state)
396
- ok_count = 0
397
- bad = []
398
- for i, h in enumerate(hyps):
399
- if not isinstance(h, dict):
400
- bad.append(f"item{i}_not_dict")
401
- continue
402
- claim = h.get("claim", "")
403
- if not isinstance(claim, str) or len(claim) < 40 or _is_filler(claim):
404
- bad.append(f"item{i}_bad_claim_len={len(claim) if isinstance(claim, str) else 0}")
405
- continue
406
- # claim should overlap with input_state (any of world_model/symptoms/diag_goal)
407
- if input_text and _jaccard(claim, input_text) < 0.03:
408
- bad.append(f"item{i}_claim_off_topic")
409
- continue
410
- for k in ["explains", "does_not_explain", "required_tests"]:
411
- v = h.get(k, [])
412
- if not isinstance(v, list) or len(v) < 1:
413
- bad.append(f"item{i}.{k}_not_list_or_empty")
414
- break
415
- sub = [x for x in v if isinstance(x, str) and len(x) >= 15 and not _is_filler(x)]
416
- if len(sub) < 1:
417
- bad.append(f"item{i}.{k}_no_substantive")
418
- break
419
- else:
420
- # required_tests must mention real tools / methods
421
- tests = h.get("required_tests", [])
422
- tool_hits = sum(1 for t in tests if isinstance(t, str) and _has_pcap_tool_token(t))
423
- if tool_hits == 0:
424
- bad.append(f"item{i}.required_tests_no_tools")
425
- continue
426
- ok_count += 1
427
- ok = ok_count >= 2
428
- fs = {"hypotheses": {"ok": ok, "format": ok_count >= 2, "content": ok_count >= 2,
429
- "reason": f"good={ok_count}/{len(hyps)} bad={bad[:3]}"}}
430
- return _result(ok, ok, fs, head)
431
-
432
-
433
- def grade_test_planner_v2(row: dict, completion: str) -> tuple[bool, dict]:
434
- """Strict: next_action must have real tool + arguments dict + expected_observations with multiple outcomes."""
435
- parsed, head = _parse(completion)
436
- if parsed is None:
437
- return _result(False, False, {}, head, "no_json")
438
-
439
- if "next_action" not in parsed:
440
- return _result(False, False, {"next_action": {"ok": False, "reason": "missing"}},
441
- head, "missing_top")
442
- na = parsed.get("next_action", {})
443
- if not isinstance(na, dict):
444
- return _result(False, False,
445
- {"next_action": {"ok": False, "format": False, "reason": "not_dict"}},
446
- head, "not_dict")
447
-
448
- # tool / tool_name (model uses either)
449
- tool = na.get("tool") or na.get("tool_name") or ""
450
- tool_ok = (isinstance(tool, str) and len(tool) >= 4 and not _is_filler(tool))
451
- # arguments: dict with ≥1 key; try multiple key names (arguments/args/params)
452
- args = na.get("arguments") or na.get("args") or na.get("params") or {}
453
- args_ok = isinstance(args, dict) and len(args) >= 1 and not all(_is_filler(str(v)) for v in args.values())
454
- # expected_observations: list with ≥2 outcomes OR dict with ≥2 keys
455
- eobs = na.get("expected_observations")
456
- if isinstance(eobs, list):
457
- eobs_ok = len(eobs) >= 2 and all(isinstance(o, dict) for o in eobs)
458
- elif isinstance(eobs, dict):
459
- eobs_ok = len(eobs) >= 2
460
- else:
461
- eobs_ok = False
462
-
463
- fs = {"next_action": {
464
- "ok": tool_ok and args_ok and eobs_ok,
465
- "format": isinstance(na, dict),
466
- "content": tool_ok and args_ok and eobs_ok,
467
- "reason": f"tool={tool[:30]!r}_ok={tool_ok} args_ok={args_ok} eobs_ok={eobs_ok}",
468
- }}
469
- ok = tool_ok and args_ok and eobs_ok
470
- return _result(ok, ok, fs, head)
471
-
472
-
473
- def grade_observation_normalizer_v2(row: dict, completion: str) -> tuple[bool, dict]:
474
- """Strict: observations must have grounded flow_id + parseable packet_ref + numeric time."""
475
- input_state = row.get("input_state", {}) or {}
476
- parsed, head = _parse(completion)
477
- if parsed is None:
478
- return _result(False, False, {}, head, "no_json")
479
-
480
- if "observations" not in parsed:
481
- return _result(False, False, {"observations": {"ok": False, "reason": "missing"}},
482
- head, "missing_top")
483
- obs = parsed.get("observations", [])
484
- if not isinstance(obs, list) or len(obs) < 1:
485
- return _result(False, False, {"observations": {"ok": False, "reason": "not_list"}},
486
- head, "not_list")
487
-
488
- input_text = _input_state_text(input_state)
489
- ok_count = 0
490
- bad = []
491
- for i, o in enumerate(obs):
492
- if not isinstance(o, dict):
493
- bad.append(f"item{i}_not_dict")
494
- continue
495
- otype = o.get("type", "")
496
- if not isinstance(otype, str) or len(otype) < 3 or _is_filler(otype):
497
- bad.append(f"item{i}_bad_type")
498
- continue
499
- flow_id = o.get("flow_id", "")
500
- # flow_id: non-filler, ≥3 char id (any chars; capagent uses e.g. "CxA123.0001", "flow_001", "f1")
501
- if not isinstance(flow_id, str) or len(flow_id) < 2 or _is_filler(flow_id):
502
- bad.append(f"item{i}_bad_flow_id")
503
- continue
504
- # packet_ref: non-filler ≥1 char (int OR string — capagent uses composite IDs like "CxA123_1698417000")
505
- pref = o.get("packet_ref")
506
- if pref is None:
507
- bad.append(f"item{i}_no_packet_ref")
508
- continue
509
- if isinstance(pref, int):
510
- pref_ok = True
511
- elif isinstance(pref, str) and len(pref) >= 1 and not _is_filler(pref):
512
- pref_ok = True
513
- else:
514
- pref_ok = False
515
- if not pref_ok:
516
- bad.append(f"item{i}_bad_packet_ref:{pref!r}")
517
- continue
518
- # time: numeric int/float OR string parseable as float
519
- t = o.get("time")
520
- t_ok = False
521
- if isinstance(t, (int, float)) and not isinstance(t, bool):
522
- t_ok = True
523
- elif isinstance(t, str):
524
- try:
525
- float(t.strip())
526
- t_ok = True
527
- except (ValueError, AttributeError):
528
- pass
529
- if not t_ok:
530
- bad.append(f"item{i}_bad_time:{t!r}")
531
- continue
532
- ok_count += 1
533
- ok = ok_count >= 1
534
- fs = {"observations": {"ok": ok, "format": ok_count >= 1, "content": ok_count >= 1,
535
- "reason": f"valid={ok_count}/{len(obs)} bad={bad[:3]}"}}
536
- return _result(ok, ok, fs, head)
537
-
538
-
539
- def grade_hypothesis_updater_v2(row: dict, completion: str) -> tuple[bool, dict]:
540
- """Strict: update id must match input hypothesis.id, reason must cite observation."""
541
- input_state = row.get("input_state", {}) or {}
542
- parsed, head = _parse(completion)
543
- if parsed is None:
544
- return _result(False, False, {}, head, "no_json")
545
-
546
- if "hypothesis_updates" not in parsed:
547
- return _result(False, False, {"hypothesis_updates": {"ok": False, "reason": "missing"}},
548
- head, "missing_top")
549
- updates = parsed.get("hypothesis_updates", [])
550
- if not isinstance(updates, list) or len(updates) < 1:
551
- return _result(False, False, {"hypothesis_updates": {"ok": False, "reason": "not_list"}},
552
- head, "not_list")
553
-
554
- # input hypothesis id
555
- h_input = input_state.get("hypothesis", {})
556
- expected_id = h_input.get("id") if isinstance(h_input, dict) else None
557
- input_text = _input_state_text(input_state)
558
-
559
- allowed_status = {
560
- "provisionally_supported", "weakened", "rejected",
561
- "confirmed", "inconclusive", "unchanged",
562
- }
563
-
564
- ok_count = 0
565
- bad = []
566
- for i, u in enumerate(updates):
567
- if not isinstance(u, dict):
568
- bad.append(f"item{i}_not_dict")
569
- continue
570
- uid = u.get("id")
571
- if expected_id and uid != expected_id:
572
- bad.append(f"item{i}_id_mismatch:{uid!r}!={expected_id!r}")
573
- continue
574
- status = u.get("status")
575
- if status not in allowed_status:
576
- bad.append(f"item{i}_bad_status:{status!r}")
577
- continue
578
- reason = u.get("reason", "")
579
- if not isinstance(reason, str) or len(reason) < 20 or _is_filler(reason):
580
- bad.append(f"item{i}_bad_reason_len={len(reason) if isinstance(reason, str) else 0}")
581
- continue
582
- # reason should overlap with input_state (was: only with observation — too narrow)
583
- if input_text and _jaccard(reason, input_text) < 0.03:
584
- bad.append(f"item{i}_reason_off_topic")
585
- continue
586
- ok_count += 1
587
- ok = ok_count >= 1
588
- fs = {"hypothesis_updates": {"ok": ok, "format": ok_count >= 1, "content": ok_count >= 1,
589
- "reason": f"valid={ok_count}/{len(updates)} bad={bad[:3]}"}}
590
- return _result(ok, ok, fs, head)
591
-
592
-
593
- def grade_stop_controller_v2(row: dict, completion: str) -> tuple[bool, dict]:
594
- """Strict: enum match expected + reason substantive + required_before_stop consistent."""
595
- expected = row.get("expected_output", {}) or {}
596
- parsed, head = _parse(completion)
597
- if parsed is None:
598
- return _result(False, False, {}, head, "no_json")
599
-
600
- if "stop_decision" not in parsed:
601
- return _result(False, False, {"stop_decision": {"ok": False, "reason": "missing"}},
602
- head, "missing_top")
603
- enum_allowed = {"continue", "stop_with_conclusion", "stop_inconclusive"}
604
- sd = parsed.get("stop_decision")
605
- if sd not in enum_allowed:
606
- return _result(False, False,
607
- {"stop_decision": {"ok": False, "format": False, "content": False,
608
- "reason": f"bad_enum:{sd!r}"}},
609
- head, "bad_enum")
610
- expected_sd = expected.get("stop_decision")
611
- sd_match = (sd == expected_sd) if expected_sd else True
612
-
613
- reason = parsed.get("reason", "")
614
- reason_ok = (isinstance(reason, str) and len(reason) >= 50 and not _is_filler(reason))
615
-
616
- rbs = parsed.get("required_before_stop", [])
617
- if sd == "continue":
618
- rbs_ok = isinstance(rbs, list) and len(rbs) >= 1 and all(isinstance(x, str) and len(x) >= 5 for x in rbs)
619
- else:
620
- rbs_ok = isinstance(rbs, list)
621
-
622
- fs = {
623
- "stop_decision": {"ok": sd_match, "format": True, "content": sd_match,
624
- "reason": f"got={sd} expected={expected_sd}"},
625
- "reason": {"ok": reason_ok, "format": isinstance(reason, str),
626
- "content": reason_ok, "reason": f"len={len(reason) if isinstance(reason, str) else 0}"},
627
- "required_before_stop": {"ok": rbs_ok, "format": isinstance(rbs, list),
628
- "content": rbs_ok, "reason": f"n={len(rbs) if isinstance(rbs, list) else 0}"},
629
- }
630
- format_ok = all(v.get("format", False) for v in fs.values())
631
- content_ok = all(v.get("content", False) for v in fs.values())
632
- return _result(format_ok, content_ok, fs, head)
633
-
634
-
635
- # ---------- registry ----------
636
-
637
- GRADERS_V2: dict[str, Any] = {
638
- "diag_problem_framing_v2": grade_problem_framing_v2,
639
- "diag_world_model_builder_v2": grade_world_model_builder_v2,
640
- "diag_symptom_detector_v2": grade_symptom_detector_v2,
641
- "diag_hypothesis_generator_v2": grade_hypothesis_generator_v2,
642
- "diag_test_planner_v2": grade_test_planner_v2,
643
- "diag_observation_normalizer_v2": grade_observation_normalizer_v2,
644
- "diag_hypothesis_updater_v2": grade_hypothesis_updater_v2,
645
- "diag_stop_controller_v2": grade_stop_controller_v2,
646
- }
647
-
648
- # v1 → v2 task mapping for regrade scripts
649
- V1_TO_V2 = {
650
- f"diag_{tid}": f"diag_{tid}_v2" for tid in DIAG_TASKS.keys()
651
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/diag/graders_v3.py DELETED
@@ -1,710 +0,0 @@
1
- """v3 graders — semantic comparison against expected_output.
2
-
3
- Key fix vs v2.1: every task explicitly compares predicted to expected_output
4
- (not just to input_state). Closes the input-copy template loophole that the
5
- operator audit exposed (v2.1 input-copy baseline = 80.9% pass; v3 target <30%).
6
-
7
- Design criteria (must satisfy ALL three):
8
- - fake-shape gibberish baseline: < 10% pass
9
- - input-copy template baseline: < 30% pass
10
- - capagent GT self-grade: ≥ 70% pass (calibrates against real noise)
11
-
12
- Per-task adds over v2.1:
13
- problem_framing → scope keys overlap with EXPECTED scope (≥50%)
14
- world_model_builder → protocol must match expected; entity count ±50%
15
- symptom_detector → symptom type ∈ expected_types set
16
- hypothesis_generator → n_hyps ±1 of expected; claim Jaccard vs EXPECTED claims (not input symptoms); shrunk tool whitelist
17
- test_planner → tool matches expected (semantic equiv class); expected_obs has hypothesis cross-impact
18
- observation_normalizer → flow_id ∈ expected_flow_ids; type ∈ expected_types
19
- hypothesis_updater → status MUST equal expected.status (was missing)
20
- stop_controller → unchanged (already expected-match)
21
- """
22
- from __future__ import annotations
23
-
24
- import json
25
- import re
26
- from typing import Any
27
-
28
- from .schemas import DIAG_TASKS, TaskSpec, FieldSpec
29
- from .graders import _extract_json, _tokens, _jaccard
30
- from .graders_v2 import (
31
- _is_filler, _is_ip, _collect_ips_from_input_state, _input_state_text,
32
- _result, _parse, _FLOW_TUPLE_RE,
33
- )
34
-
35
- # ---------- helpers ----------
36
-
37
- # Narrower tool whitelist — remove ambiguous 2-letter substrings.
38
- _PCAP_TOOL_TOKENS_V3 = {
39
- "tshark", "tcpdump", "wireshark", "zeek", "bro", "scapy",
40
- "traceroute", "tracert", "mtr", "nmap", "dig", "nslookup",
41
- "netstat", "ethtool", "iptables", "ipset", "nftables",
42
- "iperf", "iperf3", "openssl", "curl", "wget",
43
- "syn_probe", "syn_scan", "tcp_probe", "tcp_retransmit",
44
- "mtu_probe", "path_mtu", "dns_query", "icmp_ping",
45
- "tcp_stream", "kernel_log", "dmesg", "journalctl",
46
- "ipsec", "ike", "strongswan", "wireguard",
47
- }
48
-
49
- # Broader anchor set for hypothesis_generator.required_tests semantic check.
50
- # Real-world diagnostic test plans use protocol nouns ("TCP buffer", "path MTU"),
51
- # diagnostic mechanisms ("firewall logs", "NIC interrupt"), and concept words
52
- # ("pcap capture", "throughput") in addition to CLI tool names. Reviewed-accept
53
- # GT was 91% rejected by the narrow _PCAP_TOOL_TOKENS_V3 alone (2026-05-21
54
- # human-review audit). The anti-input-copy bite for hyp_gen comes from the
55
- # claim Jaccard vs EXPECTED claims (line below), not from this whitelist.
56
- _HYP_TEST_TOKENS = _PCAP_TOOL_TOKENS_V3 | {
57
- # protocols / layers
58
- "tcp", "udp", "icmp", "dns", "tls", "ssl", "http", "https", "ssh", "arp",
59
- # tcp/ip mechanisms (multi-letter, specific)
60
- "syn", "rst", "fin", "mtu", "rtt", "mss", "window",
61
- "retransmission", "retransmit", "duplicate",
62
- # network artifacts / verbs
63
- "pcap", "packet", "capture", "interface", "interrupt", "frame", "header",
64
- "ping",
65
- # network components
66
- "firewall", "nat", "router", "gateway", "switch", "nic", "vlan", "vpn",
67
- # diagnostics resources
68
- "buffer", "queue", "throughput", "latency", "jitter", "kernel",
69
- # common server/service names in test plans
70
- "nginx", "apache", "haproxy", "envoy", "log", "logs",
71
- # cpu/process diagnostics
72
- "cpu",
73
- }
74
-
75
-
76
- def _has_real_tool_token(s: str) -> bool:
77
- if not isinstance(s, str):
78
- return False
79
- sl = s.lower()
80
- return any(tok in sl for tok in _PCAP_TOOL_TOKENS_V3)
81
-
82
-
83
- def _has_test_anchor(s: str) -> bool:
84
- """Used by hypothesis_generator.required_tests check — broader than
85
- _has_real_tool_token because real diagnostic prose uses protocol/concept
86
- nouns, not only CLI tool names."""
87
- if not isinstance(s, str):
88
- return False
89
- sl = s.lower()
90
- return any(tok in sl for tok in _HYP_TEST_TOKENS)
91
-
92
-
93
- def _len_close(a, b, ratio: float = 0.5) -> bool:
94
- """True if abs(a - b) / max(b, 1) ≤ ratio."""
95
- try:
96
- return abs(a - b) / max(b, 1) <= ratio
97
- except Exception:
98
- return False
99
-
100
-
101
- # Static synonym map for enum drift observed in v7_filt fail analysis
102
- # (2026-05-21). Lowercase keys, lowercase canonical values. Only true
103
- # synonyms — NOT semantic miscategorisation (e.g. tcp_retransmission_burst
104
- # ≠ tcp_duplicate_ack, those are different TCP events and the model is
105
- # genuinely guessing wrong, not vocab-drifting).
106
- _ENUM_SYNONYMS = {
107
- # observation_normalizer.type drift
108
- "tls_handshake_complete": "tls_handshake_event",
109
- "tls_handshake": "tls_handshake_event",
110
- "tls_handshake_start": "tls_handshake_event",
111
- # protocol version aliases
112
- "tlsv1.3": "tls 1.3",
113
- "tls1.3": "tls 1.3",
114
- "tls/ssl": "tls",
115
- "tls/ssl/tls": "tls",
116
- }
117
-
118
-
119
- def _normalize_enum(s: str) -> str:
120
- """Lowercase + collapse whitespace. Idempotent."""
121
- if not isinstance(s, str):
122
- return ""
123
- return re.sub(r"\s+", " ", s.lower().strip())
124
-
125
-
126
- def _split_compound(s: str) -> set[str]:
127
- """Split a compound protocol/type like 'TCP/TLS', 'TLS 1.3 over TCP',
128
- 'SSH/TCP' into atomic parts. Keep parts of length ≥3 to avoid noise."""
129
- parts = re.split(r"[/\s,]+", _normalize_enum(s))
130
- return {p for p in parts if len(p) >= 3}
131
-
132
-
133
- def _enum_match(pred: str, allowed: set[str]) -> str | None:
134
- """Return canonical allowed value matching `pred`, else None. Tiers:
135
- (1) exact (case-insensitive),
136
- (2) explicit synonym dict,
137
- (3) compound subset (TCP/TLS matches TLS),
138
- (4) substring overlap with ratio ≥0.6 (both ≥6 chars).
139
- Strict enough to NOT cross truly different enums (TCP ≠ HTTPS, etc)."""
140
- if not isinstance(pred, str) or not pred:
141
- return None
142
- pred_n = _normalize_enum(pred)
143
- allowed_norm = {_normalize_enum(a): a for a in allowed if isinstance(a, str)}
144
- if pred_n in allowed_norm:
145
- return allowed_norm[pred_n]
146
- # synonym dict
147
- syn = _ENUM_SYNONYMS.get(pred_n)
148
- if syn and syn in allowed_norm:
149
- return allowed_norm[syn]
150
- # Compound subset — ONLY when pred is a compound (≥2 parts).
151
- # This means the model expressed multiple layers (e.g. 'TCP/TLS') and we
152
- # match it against a more atomic allowed value. Crucially, a BARE pred
153
- # like 'TCP' is NOT allowed to match an allowed compound 'TCP/TLS' — that
154
- # would let the input-copy baseline (which emits bare 'TCP') game any
155
- # compound-protocol GT. Asymmetric is intentional.
156
- pred_parts = _split_compound(pred)
157
- if len(pred_parts) >= 2:
158
- for an, orig in allowed_norm.items():
159
- an_parts = _split_compound(orig)
160
- if an_parts and an_parts <= pred_parts:
161
- return orig
162
- # Substring overlap — both sides must be ≥6 chars to avoid trivial matches.
163
- for an, orig in allowed_norm.items():
164
- if len(an) < 6 or len(pred_n) < 6:
165
- continue
166
- if an in pred_n or pred_n in an:
167
- shorter = min(len(an), len(pred_n))
168
- longer = max(len(an), len(pred_n))
169
- if shorter / longer >= 0.6:
170
- return orig
171
- return None
172
-
173
-
174
- # ---------- per-task v3 graders ----------
175
-
176
- def grade_problem_framing_v3(row: dict, completion: str) -> tuple[bool, dict]:
177
- """v2.1 + scope key overlap with EXPECTED scope."""
178
- expected = row.get("expected_output", {}) or {}
179
- input_state = row.get("input_state", {}) or {}
180
- parsed, head = _parse(completion)
181
- if parsed is None:
182
- return _result(False, False, {}, head, "no_json")
183
-
184
- required = ["diagnosis_goal", "scope", "primary_question", "constraints", "initial_uncertainties"]
185
- missing = [k for k in required if k not in parsed]
186
- if missing:
187
- return _result(False, False,
188
- {k: {"ok": False, "reason": "missing_top_key"} for k in missing},
189
- head, f"missing_top:{missing}")
190
-
191
- fs = {}
192
- user_req = input_state.get("user_request", "")
193
-
194
- # diagnosis_goal: overlap with EXPECTED goal (not just user_req)
195
- goal = parsed.get("diagnosis_goal", "")
196
- exp_goal = expected.get("diagnosis_goal", "")
197
- goal_jacc = _jaccard(goal, exp_goal) if exp_goal else _jaccard(goal, user_req)
198
- goal_ok = (isinstance(goal, str) and len(goal) >= 30 and not _is_filler(goal)
199
- and goal_jacc >= 0.15)
200
- fs["diagnosis_goal"] = {"ok": goal_ok, "format": isinstance(goal, str), "content": goal_ok,
201
- "reason": f"len={len(goal) if isinstance(goal, str) else 0} jacc_to_exp={goal_jacc:.2f}"}
202
-
203
- # scope: dict with ≥ 2 keys. If expected scope present, accept ≥ 1 key match
204
- # OR value-token Jaccard ≥ 0.10 with expected scope values (synonym keys
205
- # with similar values still pass — capagent and small student naturally use
206
- # different key vocab for the same scoping). 2026-05-21: previous ≥50% key
207
- # overlap was the dominant fail mode (73/79 v7_filt fails), but the real
208
- # anti-input-copy bite is the diagnosis_goal Jaccard above — scope keys are
209
- # not a meaningful semantic anchor.
210
- scope = parsed.get("scope", {})
211
- exp_scope = expected.get("scope", {})
212
- if not isinstance(scope, dict):
213
- fs["scope"] = {"ok": False, "format": False, "content": False, "reason": "not_dict"}
214
- elif len(scope) < 2:
215
- fs["scope"] = {"ok": False, "format": True, "content": False,
216
- "reason": f"too_few_keys={len(scope)}"}
217
- elif not isinstance(exp_scope, dict) or not exp_scope:
218
- fs["scope"] = {"ok": True, "format": True, "content": True,
219
- "reason": f"no_exp keys={list(scope)[:4]}"}
220
- else:
221
- exp_keys = set(exp_scope.keys())
222
- pred_keys = set(scope.keys())
223
- overlap = exp_keys & pred_keys
224
- # value-token Jaccard between concatenated pred values and exp values
225
- pred_val_text = " ".join(str(v) for v in scope.values())
226
- exp_val_text = " ".join(str(v) for v in exp_scope.values())
227
- val_jacc = _jaccard(pred_val_text, exp_val_text) if exp_val_text else 0
228
- ok = len(overlap) >= 1 or val_jacc >= 0.10
229
- fs["scope"] = {"ok": ok, "format": True, "content": ok,
230
- "reason": f"key_overlap={len(overlap)}/{len(exp_keys)} val_jacc={val_jacc:.2f}"}
231
-
232
- # primary_question
233
- pq = parsed.get("primary_question", "")
234
- q_tokens = {"what", "why", "how", "when", "where", "which", "?"}
235
- pq_ok = (isinstance(pq, str) and len(pq) >= 20 and not _is_filler(pq)
236
- and (any(t in pq.lower() for t in q_tokens) or "?" in pq))
237
- fs["primary_question"] = {"ok": pq_ok, "format": isinstance(pq, str), "content": pq_ok,
238
- "reason": f"len={len(pq) if isinstance(pq, str) else 0}"}
239
-
240
- # constraints & uncertainties — substantive list + topical overlap with
241
- # EXPECTED items (preferred) or user_req as fallback. 2026-05-21: previous
242
- # version forced topical match against user_req only, but GT and capable
243
- # models often phrase constraints abstractly ("limited capture point",
244
- # "single timestamp resolution") whose tokens don't appear in the request.
245
- user_tokens = _tokens(user_req)
246
- for k in ["constraints", "initial_uncertainties"]:
247
- lst = parsed.get(k, [])
248
- if not isinstance(lst, list) or len(lst) == 0:
249
- fs[k] = {"ok": False, "format": False, "content": False, "reason": "not_list_or_empty"}
250
- continue
251
- substantive = [x for x in lst if isinstance(x, str)
252
- and len(x) >= 20 and not _is_filler(x)]
253
- # topical: ≥2-token match with EXPECTED list (synonyms welcome) OR
254
- # ≥2-token match with user_req as fallback.
255
- exp_list = expected.get(k, []) or []
256
- exp_blob = " ".join(x for x in exp_list if isinstance(x, str))
257
- exp_tokens = _tokens(exp_blob) if exp_blob else set()
258
- topical = False
259
- if substantive:
260
- for x in substantive:
261
- tx = _tokens(x)
262
- if exp_tokens and len(tx & exp_tokens) >= 2:
263
- topical = True; break
264
- if user_tokens and len(tx & user_tokens) >= 2:
265
- topical = True; break
266
- if not (exp_tokens or user_tokens):
267
- topical = True # no baseline → can't check
268
- ok = len(substantive) >= 1 and topical
269
- fs[k] = {"ok": ok, "format": True, "content": ok,
270
- "reason": f"substantive={len(substantive)} topical={topical}"}
271
-
272
- format_ok = all(v.get("format", False) for v in fs.values())
273
- content_ok = all(v.get("content", False) for v in fs.values())
274
- return _result(format_ok, content_ok, fs, head)
275
-
276
-
277
- def grade_world_model_builder_v3(row: dict, completion: str) -> tuple[bool, dict]:
278
- """v2.1 + entity_count ±50% of expected + protocol must match expected."""
279
- expected = row.get("expected_output", {}) or {}
280
- input_state = row.get("input_state", {}) or {}
281
- parsed, head = _parse(completion)
282
- if parsed is None:
283
- return _result(False, False, {}, head, "no_json")
284
-
285
- required = ["entities", "flows", "capture_assumptions", "world_model_risks"]
286
- missing = [k for k in required if k not in parsed]
287
- if missing:
288
- return _result(False, False,
289
- {k: {"ok": False, "reason": "missing_top_key"} for k in missing},
290
- head, f"missing_top:{missing}")
291
-
292
- input_ips = _collect_ips_from_input_state(input_state)
293
- exp_entities = expected.get("entities", [])
294
- exp_flows = expected.get("flows", [])
295
- fs = {}
296
-
297
- # entities: valid IP + type enum + count within ±50% of expected
298
- ents = parsed.get("entities", [])
299
- if not isinstance(ents, list) or len(ents) < 2:
300
- fs["entities"] = {"ok": False, "format": False, "content": False, "reason": "not_list_or_too_few"}
301
- else:
302
- good = []
303
- for e in ents:
304
- if not isinstance(e, dict): continue
305
- ip = e.get("ip", "")
306
- etype = e.get("type", "")
307
- if not _is_ip(ip): continue
308
- if etype not in {"client", "server", "intermediate", "unknown", "router", "gateway", "proxy", "loadbalancer"}: continue
309
- good.append(ip)
310
- n_overlap = len(set(good) & input_ips) if input_ips else 0
311
- # count-match with expected
312
- exp_n = len(exp_entities) if isinstance(exp_entities, list) else 2
313
- count_ok = _len_close(len(good), exp_n, ratio=0.7) # ±70% — capagent variable
314
- ip_ok = (not input_ips or n_overlap >= 1)
315
- ok = (len(good) >= 2 and ip_ok and count_ok)
316
- fs["entities"] = {"ok": ok, "format": len(good) >= 2,
317
- "content": ip_ok and count_ok,
318
- "reason": f"good={len(good)} vs exp={exp_n} overlap={n_overlap}/{len(input_ips)} count_ok={count_ok}"}
319
-
320
- # flows: valid tuple + protocol must SEMANTICALLY match expected
321
- # Uses _enum_match (compound split + synonym + substring) so that
322
- # pred='TCP/TLS' against exp='TLS' or pred='TLSv1.3' against exp='TLS 1.3'
323
- # passes — 27 of v7_filt's protocol fails are this normalisation pattern.
324
- exp_protocols = set()
325
- if isinstance(exp_flows, list):
326
- for f in exp_flows:
327
- if isinstance(f, dict):
328
- p = f.get("protocol", "")
329
- if isinstance(p, str):
330
- exp_protocols.add(p)
331
- flows = parsed.get("flows", [])
332
- if not isinstance(flows, list) or len(flows) < 1:
333
- fs["flows"] = {"ok": False, "format": False, "content": False, "reason": "not_list_or_empty"}
334
- else:
335
- ok_count = 0
336
- protocol_match_count = 0
337
- for f in flows:
338
- if not isinstance(f, dict): continue
339
- t = f.get("tuple"); p = f.get("protocol", "")
340
- t_ok = False
341
- if isinstance(t, str) and _FLOW_TUPLE_RE.match(t.strip()):
342
- t_ok = True
343
- elif isinstance(t, dict):
344
- src_keys = {"src_ip", "source_ip", "src", "source"}
345
- dst_keys = {"dst_ip", "destination_ip", "dst", "destination"}
346
- t_ok = any(k in t for k in src_keys) and any(k in t for k in dst_keys)
347
- if t_ok and isinstance(p, str) and len(p) >= 2:
348
- ok_count += 1
349
- if not exp_protocols or _enum_match(p, exp_protocols) is not None:
350
- protocol_match_count += 1
351
- # require ≥1 flow with valid structure AND (if expected has protocols) ≥1 protocol match
352
- ok = ok_count >= 1 and (not exp_protocols or protocol_match_count >= 1)
353
- fs["flows"] = {"ok": ok, "format": ok_count >= 1, "content": ok,
354
- "reason": f"valid={ok_count}/{len(flows)} proto_match={protocol_match_count} exp_proto={list(exp_protocols)[:3]}"}
355
-
356
- # capture_assumptions: substantive list
357
- ca = parsed.get("capture_assumptions", [])
358
- sub = [x for x in (ca if isinstance(ca, list) else [])
359
- if isinstance(x, str) and len(x) >= 30 and not _is_filler(x)]
360
- fs["capture_assumptions"] = {"ok": len(sub) >= 1, "format": isinstance(ca, list),
361
- "content": len(sub) >= 1, "reason": f"substantive={len(sub)}"}
362
-
363
- wmr = parsed.get("world_model_risks", [])
364
- sub = [x for x in (wmr if isinstance(wmr, list) else [])
365
- if isinstance(x, str) and len(x) >= 20 and not _is_filler(x)]
366
- fs["world_model_risks"] = {"ok": len(sub) >= 1, "format": isinstance(wmr, list),
367
- "content": len(sub) >= 1, "reason": f"substantive={len(sub)}"}
368
-
369
- format_ok = all(v.get("format", False) for v in fs.values())
370
- content_ok = all(v.get("content", False) for v in fs.values())
371
- return _result(format_ok, content_ok, fs, head)
372
-
373
-
374
- def grade_symptom_detector_v3(row: dict, completion: str) -> tuple[bool, dict]:
375
- """v2.1 + symptom type MUST be in expected_types set."""
376
- expected = row.get("expected_output", {}) or {}
377
- input_state = row.get("input_state", {}) or {}
378
- parsed, head = _parse(completion)
379
- if parsed is None:
380
- return _result(False, False, {}, head, "no_json")
381
-
382
- symptoms = parsed.get("symptoms", [])
383
- if not isinstance(symptoms, list) or len(symptoms) < 1:
384
- return _result(False, False, {"symptoms": {"ok": False, "reason": "not_list"}}, head, "not_list")
385
-
386
- allowed_types = {
387
- "tcp_retransmission_burst", "tcp_duplicate_ack", "tcp_zero_window",
388
- "tcp_reset", "server_response_gap", "client_idle_gap",
389
- "tls_handshake_delay", "dns_lookup_delay", "dns_no_response",
390
- "rtt_spike", "high_jitter", "mtu_blackhole",
391
- }
392
- sev_allowed = {"low", "medium", "high", "critical"}
393
-
394
- # expected types from row's expected_output
395
- exp_symptoms = expected.get("symptoms", [])
396
- exp_types = set()
397
- if isinstance(exp_symptoms, list):
398
- for s in exp_symptoms:
399
- if isinstance(s, dict) and s.get("type") in allowed_types:
400
- exp_types.add(s["type"])
401
-
402
- input_text = _input_state_text(input_state).lower()
403
- ok_count = 0; type_match = 0; bad = []
404
- for i, s in enumerate(symptoms):
405
- if not isinstance(s, dict):
406
- bad.append(f"item{i}_not_dict"); continue
407
- stype = s.get("type")
408
- if stype not in allowed_types:
409
- bad.append(f"item{i}_bad_type:{stype}"); continue
410
- if exp_types and stype in exp_types:
411
- type_match += 1
412
- if s.get("severity") not in sev_allowed:
413
- bad.append(f"item{i}_bad_severity"); continue
414
- refs = s.get("evidence_refs", [])
415
- if not isinstance(refs, list) or len(refs) == 0:
416
- bad.append(f"item{i}_no_refs"); continue
417
- sub_refs = [r for r in refs if isinstance(r, str) and len(r) >= 4 and not _is_filler(r)]
418
- if len(sub_refs) == 0:
419
- bad.append(f"item{i}_filler_refs"); continue
420
- ref_tokens = _tokens(" ".join(sub_refs))
421
- input_tokens = _tokens(input_text)
422
- if ref_tokens and not (ref_tokens & input_tokens):
423
- bad.append(f"item{i}_ungrounded_refs"); continue
424
- ok_count += 1
425
-
426
- # require ≥1 valid symptom AND (if expected has types) ≥1 type match
427
- type_ok = (not exp_types) or (type_match >= 1)
428
- ok = (ok_count >= 1) and type_ok
429
- fs = {"symptoms": {"ok": ok, "format": ok_count >= 1, "content": ok,
430
- "reason": f"valid={ok_count}/{len(symptoms)} type_match={type_match} exp_types={list(exp_types)[:3]} bad={bad[:3]}"}}
431
- return _result(ok, ok, fs, head)
432
-
433
-
434
- def grade_hypothesis_generator_v3(row: dict, completion: str) -> tuple[bool, dict]:
435
- """v2.1 + n_hyps ±1 of expected + claim Jaccard with EXPECTED claims + narrower tool whitelist."""
436
- expected = row.get("expected_output", {}) or {}
437
- parsed, head = _parse(completion)
438
- if parsed is None:
439
- return _result(False, False, {}, head, "no_json")
440
-
441
- hyps = parsed.get("hypotheses", [])
442
- if not isinstance(hyps, list) or len(hyps) < 2:
443
- return _result(False, False,
444
- {"hypotheses": {"ok": False, "format": False, "content": False,
445
- "reason": f"only_{len(hyps) if isinstance(hyps, list) else 0}"}},
446
- head, "too_few_hyps")
447
-
448
- exp_hyps = expected.get("hypotheses", [])
449
- exp_n = len(exp_hyps) if isinstance(exp_hyps, list) else 2
450
- # claims joined as reference text for predicted claim comparison
451
- exp_claim_text = " ".join(h.get("claim", "") for h in exp_hyps if isinstance(h, dict))
452
-
453
- # count: predicted ±1 of expected
454
- count_ok = abs(len(hyps) - exp_n) <= 1
455
-
456
- ok_count = 0; bad = []
457
- for i, h in enumerate(hyps):
458
- if not isinstance(h, dict):
459
- bad.append(f"item{i}_not_dict"); continue
460
- claim = h.get("claim", "")
461
- if not isinstance(claim, str) or len(claim) < 40 or _is_filler(claim):
462
- bad.append(f"item{i}_bad_claim_len={len(claim) if isinstance(claim, str) else 0}"); continue
463
- # claim should overlap with EXPECTED claims (not just input symptoms)
464
- if exp_claim_text and _jaccard(claim, exp_claim_text) < 0.10:
465
- bad.append(f"item{i}_claim_off_topic_vs_expected"); continue
466
- # Substantive check: capagent may emit strings OR dicts (`{"test": "...", "expected_if_true": "..."}`)
467
- # Stringify for length/filler check.
468
- item_ok = True
469
- for k in ["explains", "does_not_explain", "required_tests"]:
470
- v = h.get(k, [])
471
- if not isinstance(v, list) or len(v) < 1:
472
- bad.append(f"item{i}.{k}_not_list_or_empty"); item_ok = False; break
473
- sub = []
474
- for x in v:
475
- if isinstance(x, str):
476
- s = x
477
- elif isinstance(x, dict):
478
- s = " ".join(str(vv) for vv in x.values() if isinstance(vv, (str, int, float)))
479
- else:
480
- continue
481
- if len(s) >= 15 and not _is_filler(s):
482
- sub.append(s)
483
- if len(sub) < 1:
484
- bad.append(f"item{i}.{k}_no_substantive"); item_ok = False; break
485
- if not item_ok:
486
- continue
487
- # required_tests: stringify whole list and check any network-domain
488
- # anchor token (protocols, mechanisms, components, or CLI tools).
489
- # Narrower _has_real_tool_token would reject natural-language diagnostic
490
- # prose; the input-copy defence for hyp_gen lives in the claim Jaccard
491
- # check above, not in this whitelist.
492
- tests = h.get("required_tests", [])
493
- tests_blob = " ".join(
494
- x if isinstance(x, str)
495
- else " ".join(str(v) for v in x.values() if isinstance(v, (str, int, float)))
496
- for x in tests
497
- )
498
- if not _has_test_anchor(tests_blob):
499
- bad.append(f"item{i}.required_tests_no_tools"); continue
500
- ok_count += 1
501
- ok = ok_count >= 2 and count_ok
502
- fs = {"hypotheses": {"ok": ok, "format": ok_count >= 2, "content": ok,
503
- "reason": f"good={ok_count}/{len(hyps)} count_ok={count_ok} (pred {len(hyps)} vs exp {exp_n}) bad={bad[:3]}"}}
504
- return _result(ok, ok, fs, head)
505
-
506
-
507
- def grade_test_planner_v3(row: dict, completion: str) -> tuple[bool, dict]:
508
- """v2.1 + tool semantic equivalence with expected_tool + expected_observations has cross-hypothesis impact."""
509
- expected = row.get("expected_output", {}) or {}
510
- parsed, head = _parse(completion)
511
- if parsed is None:
512
- return _result(False, False, {}, head, "no_json")
513
-
514
- na = parsed.get("next_action")
515
- if not isinstance(na, dict):
516
- return _result(False, False, {"next_action": {"ok": False, "reason": "missing_or_not_dict"}}, head, "")
517
-
518
- exp_na = expected.get("next_action", {}) if isinstance(expected.get("next_action"), dict) else {}
519
- exp_tool = (exp_na.get("tool") or exp_na.get("tool_name") or "").lower()
520
-
521
- tool = (na.get("tool") or na.get("tool_name") or "")
522
- tool_ok = (isinstance(tool, str) and len(tool) >= 4 and not _is_filler(tool))
523
- # tool semantic match: substring overlap with expected tool OR shared real-tool token
524
- tool_match_exp = True
525
- if exp_tool and tool:
526
- tool_l = tool.lower()
527
- # exact match OR shared real-tool token OR ≥4-char shared substring
528
- shared = (_has_real_tool_token(tool) and _has_real_tool_token(exp_tool)
529
- and any(tok in tool_l and tok in exp_tool for tok in _PCAP_TOOL_TOKENS_V3))
530
- tool_match_exp = (tool_l == exp_tool or shared
531
- or any(tool_l[i:i+5] in exp_tool for i in range(len(tool_l) - 4))
532
- or any(exp_tool[i:i+5] in tool_l for i in range(len(exp_tool) - 4)))
533
-
534
- args = na.get("arguments") or na.get("args") or na.get("params") or {}
535
- args_ok = isinstance(args, dict) and len(args) >= 1 and not all(_is_filler(str(v)) for v in args.values())
536
-
537
- # expected_observations: ≥2 outcomes; at least one outcome must reference
538
- # a hypothesis id (impact dict OR "h1"/"h2"/"H1" token in any value string).
539
- eobs = na.get("expected_observations")
540
- _HYP_RE = re.compile(r"\b[Hh][12345]\b")
541
-
542
- def _has_hyp_ref(item) -> bool:
543
- if isinstance(item, dict):
544
- if any(k in item for k in ("impact", "hypothesis_impact", "h_impact")):
545
- return True
546
- # search all values for hypothesis token
547
- for v in item.values():
548
- if isinstance(v, str) and _HYP_RE.search(v):
549
- return True
550
- if isinstance(v, dict):
551
- for vv in v.values():
552
- if isinstance(vv, str) and _HYP_RE.search(vv):
553
- return True
554
- elif isinstance(item, str):
555
- if _HYP_RE.search(item):
556
- return True
557
- return False
558
-
559
- if isinstance(eobs, list):
560
- n = len(eobs)
561
- has_impact = sum(1 for o in eobs if _has_hyp_ref(o))
562
- elif isinstance(eobs, dict):
563
- n = len(eobs)
564
- has_impact = sum(1 for v in eobs.values() if _has_hyp_ref(v))
565
- else:
566
- n = 0; has_impact = 0
567
- eobs_ok = n >= 2 and has_impact >= 1
568
-
569
- ok = tool_ok and tool_match_exp and args_ok and eobs_ok
570
- fs = {"next_action": {
571
- "ok": ok, "format": isinstance(na, dict), "content": ok,
572
- "reason": f"tool={tool[:30]!r} tool_match_exp={tool_match_exp} args_ok={args_ok} eobs_n={n} impact={has_impact}",
573
- }}
574
- return _result(ok, ok, fs, head)
575
-
576
-
577
- def grade_observation_normalizer_v3(row: dict, completion: str) -> tuple[bool, dict]:
578
- """v2.1 + flow_id ∈ expected_flow_ids + type ∈ expected_types."""
579
- expected = row.get("expected_output", {}) or {}
580
- parsed, head = _parse(completion)
581
- if parsed is None:
582
- return _result(False, False, {}, head, "no_json")
583
-
584
- obs = parsed.get("observations", [])
585
- if not isinstance(obs, list) or len(obs) < 1:
586
- return _result(False, False, {"observations": {"ok": False, "reason": "not_list"}}, head, "")
587
-
588
- exp_obs = expected.get("observations", [])
589
- exp_flow_ids = set()
590
- exp_types = set()
591
- if isinstance(exp_obs, list):
592
- for e in exp_obs:
593
- if isinstance(e, dict):
594
- if e.get("flow_id"): exp_flow_ids.add(str(e["flow_id"]))
595
- if e.get("type"): exp_types.add(str(e["type"]))
596
-
597
- ok_count = 0; flow_match_count = 0; type_match_count = 0; bad = []
598
- for i, o in enumerate(obs):
599
- if not isinstance(o, dict):
600
- bad.append(f"item{i}_not_dict"); continue
601
- otype = o.get("type", "")
602
- if not isinstance(otype, str) or len(otype) < 3 or _is_filler(otype):
603
- bad.append(f"item{i}_bad_type"); continue
604
- flow_id = o.get("flow_id", "")
605
- if not isinstance(flow_id, str) or len(flow_id) < 2 or _is_filler(flow_id):
606
- bad.append(f"item{i}_bad_flow_id"); continue
607
- pref = o.get("packet_ref")
608
- if pref is None: bad.append(f"item{i}_no_packet_ref"); continue
609
- pref_ok = isinstance(pref, int) or (isinstance(pref, str) and len(pref) >= 1 and not _is_filler(pref))
610
- if not pref_ok: bad.append(f"item{i}_bad_packet_ref:{pref!r}"); continue
611
- t = o.get("time")
612
- t_ok = False
613
- if isinstance(t, (int, float)) and not isinstance(t, bool): t_ok = True
614
- elif isinstance(t, str):
615
- try: float(t.strip()); t_ok = True
616
- except: pass
617
- if not t_ok: bad.append(f"item{i}_bad_time:{t!r}"); continue
618
- # semantic: at least one of {flow_id, type} must match expected (when expected has any).
619
- # type uses _enum_match for synonym/substring tolerance — pred='tls_handshake_complete'
620
- # matches exp='tls_handshake_event' via _ENUM_SYNONYMS (2026-05-21 audit found
621
- # this as the dominant obs_norm type-drift pattern).
622
- if exp_flow_ids and flow_id in exp_flow_ids: flow_match_count += 1
623
- if exp_types and _enum_match(otype, exp_types) is not None: type_match_count += 1
624
- ok_count += 1
625
- # require ≥1 valid AND (if expected has types or flow_ids) ≥1 match on type or flow_id
626
- semantic_ok = True
627
- if exp_types or exp_flow_ids:
628
- semantic_ok = (flow_match_count >= 1) or (type_match_count >= 1)
629
- ok = ok_count >= 1 and semantic_ok
630
- fs = {"observations": {"ok": ok, "format": ok_count >= 1, "content": ok,
631
- "reason": f"valid={ok_count}/{len(obs)} flow_match={flow_match_count} type_match={type_match_count} exp_types={list(exp_types)[:3]}"}}
632
- return _result(ok, ok, fs, head)
633
-
634
-
635
- def grade_hypothesis_updater_v3(row: dict, completion: str) -> tuple[bool, dict]:
636
- """v2.1 + status MUST equal expected.status (was missing in v2.1)."""
637
- expected = row.get("expected_output", {}) or {}
638
- input_state = row.get("input_state", {}) or {}
639
- parsed, head = _parse(completion)
640
- if parsed is None:
641
- return _result(False, False, {}, head, "no_json")
642
-
643
- updates = parsed.get("hypothesis_updates", [])
644
- if not isinstance(updates, list) or len(updates) < 1:
645
- return _result(False, False, {"hypothesis_updates": {"ok": False, "reason": "not_list"}}, head, "")
646
-
647
- exp_updates = expected.get("hypothesis_updates", [])
648
- exp_id_to_status = {}
649
- if isinstance(exp_updates, list):
650
- for u in exp_updates:
651
- if isinstance(u, dict) and u.get("id") and u.get("status"):
652
- exp_id_to_status[u["id"]] = u["status"]
653
-
654
- h_input = input_state.get("hypothesis", {})
655
- expected_id = h_input.get("id") if isinstance(h_input, dict) else None
656
- input_text = _input_state_text(input_state)
657
-
658
- allowed_status = {
659
- "provisionally_supported", "weakened", "rejected",
660
- "confirmed", "inconclusive", "unchanged",
661
- }
662
-
663
- ok_count = 0; status_match = 0; bad = []
664
- for i, u in enumerate(updates):
665
- if not isinstance(u, dict):
666
- bad.append(f"item{i}_not_dict"); continue
667
- uid = u.get("id")
668
- if expected_id and uid != expected_id:
669
- bad.append(f"item{i}_id_mismatch:{uid!r}!={expected_id!r}"); continue
670
- status = u.get("status")
671
- if status not in allowed_status:
672
- bad.append(f"item{i}_bad_status:{status!r}"); continue
673
- # NEW v3: status MUST match expected (if we have an expected status for this id)
674
- exp_st = exp_id_to_status.get(uid)
675
- if exp_st and status != exp_st:
676
- bad.append(f"item{i}_status_mismatch:{status!r}!={exp_st!r}"); continue
677
- if exp_st:
678
- status_match += 1
679
- reason = u.get("reason", "")
680
- if not isinstance(reason, str) or len(reason) < 20 or _is_filler(reason):
681
- bad.append(f"item{i}_bad_reason"); continue
682
- if input_text and _jaccard(reason, input_text) < 0.03:
683
- bad.append(f"item{i}_reason_off_topic"); continue
684
- ok_count += 1
685
- ok = ok_count >= 1
686
- fs = {"hypothesis_updates": {"ok": ok, "format": ok_count >= 1, "content": ok,
687
- "reason": f"valid={ok_count}/{len(updates)} status_match={status_match} bad={bad[:3]}"}}
688
- return _result(ok, ok, fs, head)
689
-
690
-
691
- def grade_stop_controller_v3(row: dict, completion: str) -> tuple[bool, dict]:
692
- """Unchanged from v2.1 — already does expected enum match."""
693
- from .graders_v2 import grade_stop_controller_v2
694
- return grade_stop_controller_v2(row, completion)
695
-
696
-
697
- # ---------- registry ----------
698
-
699
- GRADERS_V3: dict[str, Any] = {
700
- "diag_problem_framing_v3": grade_problem_framing_v3,
701
- "diag_world_model_builder_v3": grade_world_model_builder_v3,
702
- "diag_symptom_detector_v3": grade_symptom_detector_v3,
703
- "diag_hypothesis_generator_v3": grade_hypothesis_generator_v3,
704
- "diag_test_planner_v3": grade_test_planner_v3,
705
- "diag_observation_normalizer_v3": grade_observation_normalizer_v3,
706
- "diag_hypothesis_updater_v3": grade_hypothesis_updater_v3,
707
- "diag_stop_controller_v3": grade_stop_controller_v3,
708
- }
709
-
710
- V1_TO_V3 = {f"diag_{tid}": f"diag_{tid}_v3" for tid in DIAG_TASKS.keys()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/diag/schemas.py DELETED
@@ -1,341 +0,0 @@
1
- """Task specifications for the 8 agentic diagnosis micro-tasks (PCAP domain).
2
-
3
- Each TaskSpec is shared by:
4
- - GT generator (experiments/ksbr_pcap/scripts/gen_diag_eval_gt.py) — capagent gets the schema + 3-shot examples, produces JSON
5
- - Grader (nanoai.agentic.diag.graders) — validates `format_ok` against required keys/enums and `content_ok` against expected output
6
-
7
- Design notes:
8
- * The schemas are intentionally **minimal** — only fields whose absence/wrongness
9
- would prevent a downstream agent step from operating. Avoid demanding fields
10
- the model can't reasonably infer from input_state alone.
11
- * `freetext=True` flags fields where wording will vary across models; graders
12
- apply Jaccard overlap (≥0.2 = some semantic signal) rather than exact match.
13
- * `enum_values` are STRICT — a model that emits a non-enum value scores
14
- content_ok=False on that field. Enums are chosen to be the natural vocab a
15
- domain-trained model would use.
16
- """
17
- from __future__ import annotations
18
-
19
- from dataclasses import dataclass, field
20
- from typing import Literal
21
-
22
-
23
- FieldKind = Literal["str", "int", "float", "enum", "list", "list_obj", "obj"]
24
-
25
-
26
- @dataclass
27
- class FieldSpec:
28
- name: str
29
- kind: FieldKind
30
- required: bool = True
31
- # for kind=enum
32
- enum_values: list[str] | None = None
33
- # for kind=list / list_obj: minimum item count for the field to count as "present"
34
- min_items: int = 1
35
- # for kind=list_obj: required keys on each item
36
- item_keys: list[str] | None = None
37
- # for kind=list_obj: per-item enum constraints (key → allowed values)
38
- item_enums: dict[str, list[str]] | None = None
39
- # mark string fields where the model's exact wording will vary;
40
- # grader uses Jaccard token overlap instead of equality
41
- freetext: bool = False
42
-
43
-
44
- @dataclass
45
- class TaskSpec:
46
- task_id: str # short slug, matches eval row's micro_task
47
- description: str # one-line human description
48
- input_keys: list[str] # keys expected in input_state JSON
49
- output_fields: list[FieldSpec] # what target output must contain
50
- prompt_intro: str # plain-NL framing prepended to JSON prompt
51
- seed_scenarios: list[str] = field(default_factory=list) # PCAP scenarios for GT generation
52
-
53
-
54
- # ---------- 8 task definitions ----------
55
-
56
- PROBLEM_FRAMING = TaskSpec(
57
- task_id="problem_framing",
58
- description="Given a vague user request, produce a scoped diagnosis goal.",
59
- input_keys=["user_request", "available_data", "initial_context"],
60
- output_fields=[
61
- FieldSpec("diagnosis_goal", "str", freetext=True),
62
- FieldSpec("scope", "obj"), # dict; we only check it exists + is dict
63
- FieldSpec("primary_question", "str", freetext=True),
64
- FieldSpec("constraints", "list", min_items=1),
65
- FieldSpec("initial_uncertainties", "list", min_items=1),
66
- ],
67
- prompt_intro=(
68
- "you are a network-diagnosis planner. given a user request and the "
69
- "data they have, produce a scoped diagnosis plan as JSON. think about "
70
- "what we *don't yet know* — that's the most important field."
71
- ),
72
- seed_scenarios=[
73
- "user complains a specific HTTPS API is slow at 10:00-10:15, has pcap from the client side only",
74
- "user says SSH connections drop intermittently, has pcap from a tap port between client and server",
75
- "user reports DNS lookups failing randomly for a list of domains, has pcap from the resolver",
76
- "user observes TCP RSTs during file uploads to S3, has client-side pcap",
77
- "user notices video conferencing call quality degrades after 10 min, capture point unknown",
78
- "user finds requests timing out for one specific backend, has tap-port pcap server-side",
79
- "user reports the database connection pool exhaustion alerts during peak hours, pcap from the app tier",
80
- "user sees high TLS handshake latency from one client subnet, pcap from server edge",
81
- ],
82
- )
83
-
84
- WORLD_MODEL_BUILDER = TaskSpec(
85
- task_id="world_model_builder",
86
- description="From packet/flow summary, build the entity-flow-assumption world model.",
87
- input_keys=["packet_summary", "flow_summary", "capture_metadata"],
88
- output_fields=[
89
- FieldSpec("entities", "list_obj", min_items=2,
90
- item_keys=["id", "type", "ip"],
91
- item_enums={"type": ["client", "server", "intermediate", "unknown"]}),
92
- FieldSpec("flows", "list_obj", min_items=1,
93
- item_keys=["id", "tuple", "protocol"]),
94
- FieldSpec("capture_assumptions", "list", min_items=1),
95
- FieldSpec("world_model_risks", "list", min_items=1),
96
- ],
97
- prompt_intro=(
98
- "you are a pcap world-model builder. parse a packet/flow summary into "
99
- "entities (client/server/intermediate), flows (5-tuple + protocol), and "
100
- "explicit assumptions about the capture point. also list risks that could "
101
- "make this model wrong (e.g. NAT, asymmetric capture, TLS hiding payload)."
102
- ),
103
- seed_scenarios=[
104
- "summary of HTTPS flow, client 192.168.1.20 → server 10.1.2.3:443, ~127 packets",
105
- "summary of bidirectional SSH flow with several retransmissions and dup ACKs",
106
- "summary of DNS UDP/53 query + response flow with one query showing no response",
107
- "summary of TCP flow ending in RST from server side during a long upload",
108
- "summary of unidirectional capture showing only one direction of an SMTP exchange",
109
- "summary of TLS 1.3 handshake with two flows from same client IP (port pair changed)",
110
- "summary of multiple HTTPS flows from a CDN edge with shared server IP",
111
- "summary of port-scan-like traffic to many destinations from one source IP",
112
- ],
113
- )
114
-
115
- SYMPTOM_DETECTOR = TaskSpec(
116
- task_id="symptom_detector",
117
- description="Identify symptoms in flow/event data WITHOUT explaining cause.",
118
- input_keys=["flow_events", "tcp_analysis", "time_window"],
119
- output_fields=[
120
- FieldSpec("symptoms", "list_obj", min_items=1,
121
- item_keys=["type", "flow_id", "time_range", "severity", "evidence_refs"],
122
- item_enums={
123
- "type": [
124
- "tcp_retransmission_burst", "tcp_duplicate_ack", "tcp_zero_window",
125
- "tcp_reset", "server_response_gap", "client_idle_gap",
126
- "tls_handshake_delay", "dns_lookup_delay", "dns_no_response",
127
- "rtt_spike", "high_jitter", "mtu_blackhole",
128
- ],
129
- "severity": ["low", "medium", "high", "critical"],
130
- }),
131
- ],
132
- prompt_intro=(
133
- "you are a pcap symptom detector. produce ONLY observed phenomena — do "
134
- "NOT explain cause. e.g. 'retransmission burst from 10:03:11 to 10:03:13' "
135
- "is a symptom; 'network is congested' is a hypothesis (don't write that). "
136
- "every symptom must cite evidence_refs to specific events from the input."
137
- ),
138
- seed_scenarios=[
139
- "flow events showing 15 retransmissions clustered in a 2-second window inside a 5-minute flow",
140
- "tcp analysis showing 3 zero-window advertisements from server during file upload",
141
- "flow events showing a 2300ms gap between request bytes finishing and first response byte",
142
- "dns events showing 4/40 queries received no response within 5s timeout",
143
- "tcp analysis showing alternating SYN-ACK then RST without ESTABLISHED state",
144
- "tls events showing client hello → server hello gap of 1.8 seconds (typical: <100ms)",
145
- "tcp events showing duplicate ACKs every 200ms with no progress in seq numbers",
146
- "flow events showing RTT jumping from baseline 5ms to 250ms intermittently",
147
- ],
148
- )
149
-
150
- HYPOTHESIS_GENERATOR = TaskSpec(
151
- task_id="hypothesis_generator",
152
- description="Given symptoms + world model + any prior hypotheses, propose OR refine candidate root-cause hypotheses.",
153
- input_keys=["world_model", "symptoms", "diagnosis_goal", "prior_hypotheses"],
154
- output_fields=[
155
- FieldSpec("hypotheses", "list_obj", min_items=2,
156
- item_keys=["id", "claim", "explains", "does_not_explain", "required_tests"]),
157
- ],
158
- prompt_intro=(
159
- "you are a pcap hypothesis generator running inside a multi-turn diagnosis "
160
- "loop. given world_model + symptoms + diagnosis_goal AND any prior_hypotheses "
161
- "from earlier turns, produce the CURRENT hypothesis list.\n"
162
- " - if prior_hypotheses is empty (turn 1): generate 2-3 fresh competing root "
163
- " causes. assign stable ids (H1, H2, H3).\n"
164
- " - if prior_hypotheses is non-empty (turn 2+): REFINE rather than regenerate. "
165
- " KEEP the original id of any hypothesis that remains plausible given new "
166
- " symptoms; UPDATE its claim/required_tests if evidence has narrowed it; "
167
- " ADD a new hypothesis ONLY if symptoms reveal a path the prior list doesn't "
168
- " explain; DROP hypotheses marked status=rejected by hypothesis_updater. "
169
- " NEVER renumber stable hypotheses — H1 stays H1 across turns. NEVER reset "
170
- " the slate just because new symptoms arrived; build on prior reasoning.\n"
171
- "EVERY hypothesis must include `required_tests` that could falsify it. "
172
- "each `claim` must be specific (e.g. 'server application is the bottleneck, "
173
- "not network' not just 'application issue'). do NOT pick a single root cause — "
174
- "produce ≥2 competing hypotheses unless prior_hypotheses is already down to 1 "
175
- "confirmed.\n"
176
- "\n"
177
- "FIELD QUALITY (strict — rows violating these are dropped at gen-time):\n"
178
- " - `explains`: list of ≥2 FULL-SENTENCE descriptions of what the hypothesis "
179
- " accounts for. Each item must be a complete English clause (≥15 chars), "
180
- " not a bare id like 'evt_1' or 'S2'. EXAMPLE good: 'Retransmissions cluster "
181
- " during the gap window because the server stops ACK-ing'. EXAMPLE bad: "
182
- " 'evt_3' or 'S2'.\n"
183
- " - `does_not_explain`: list of ≥1 FULL-SENTENCE description of what this "
184
- " hypothesis FAILS to account for. NEVER emit empty []; every honest "
185
- " hypothesis has gaps. If you can't think of one, the hypothesis is too "
186
- " broad — narrow it. EXAMPLE good: 'Does not explain why baseline RTT is "
187
- " unaffected outside the gap window'. EXAMPLE bad: empty list or just "
188
- " 'Why X' (incomplete).\n"
189
- " - `required_tests`: list of ≥2 FULL-SENTENCE testable actions. Each must "
190
- " name a concrete diagnostic mechanism (tool, protocol, mechanism). "
191
- " EXAMPLE good: 'Run tshark with filter ip.addr == X to capture handshake "
192
- " timing'. EXAMPLE bad: 'check it' or 'verify'."
193
- ),
194
- seed_scenarios=[
195
- # Turn-1 scenarios (empty prior_hypotheses):
196
- "symptom: 2300ms server response gap with NO tcp anomalies in the gap window",
197
- "symptom: retransmission burst + duplicate ACKs concentrated in delay windows",
198
- "symptom: TLS handshake delay 1.8s on one specific client subnet only",
199
- "symptom: DNS resolution failures clustered to one specific upstream resolver",
200
- "symptom: zero-window advertisements from server only during large upload bursts",
201
- "symptom: tcp RSTs from server side mid-flow after ~30s of activity",
202
- "symptom: MTU blackhole-like behavior (small packets ok, large get dropped)",
203
- "symptom: high jitter + occasional rtt spikes correlated with backup job times",
204
- # Turn-N>=2 scenarios (non-empty prior_hypotheses — refinement):
205
- "prior: [H1 server-bottleneck status=provisionally_supported, H2 network-congestion status=weakened] + new evidence: server CPU stays <30% during gap → DROP H1, ADD H3 about middlebox processing",
206
- "prior: [H1 TLS-handshake-misconfig, H2 firewall-rate-limit] + new evidence: TLS handshake completes normally for control flow → MARK H1 weakened, KEEP H2, ADD nothing — refinement only",
207
- "prior: [H1 path-MTU-blackhole status=provisionally_supported] + new evidence: small-packet pings also fail intermittently → BROADEN H1 claim to 'path quality degraded', keep id",
208
- "prior: [H1 dns-resolver-failure status=rejected, H2 client-stub-misconfig status=provisionally_supported] + new evidence: another resolver also fails → DROP H1 (already rejected), keep H2, ADD H3 about client name resolution policy",
209
- "prior: [H1 server-app-bottleneck status=confirmed] + new evidence: no contradicting observations after 3 tests → KEEP H1 only, downstream stop_controller should fire",
210
- "prior: [H1, H2, H3 all status=inconclusive] + new evidence: a 4th retransmission burst in a new flow → consider whether one prior hyp now explains the broader pattern; refine the strongest claim",
211
- ],
212
- )
213
-
214
- TEST_PLANNER = TaskSpec(
215
- task_id="test_planner",
216
- description="Choose the next-best tool action to validate/falsify a hypothesis.",
217
- input_keys=["hypotheses", "evidence_so_far", "available_tools"],
218
- output_fields=[
219
- FieldSpec("next_action", "obj"), # we'll require nested keys via custom check in grader
220
- ],
221
- prompt_intro=(
222
- "you are a pcap test planner. choose the SINGLE next action with the highest "
223
- "expected information gain — i.e. it should EITHER strongly support OR strongly "
224
- "weaken at least one hypothesis. the action must include a concrete tool command "
225
- "and explicit `expected_observations` mapping outcomes to hypothesis support/weaken."
226
- ),
227
- seed_scenarios=[
228
- "two hypotheses: app-delay vs network-loss; need test to distinguish them inside the delay window",
229
- "two hypotheses: server CPU bottleneck vs downstream call delay; need a test using only pcap",
230
- "two hypotheses: client-side firewall vs path MTU; need a test that disambiguates",
231
- "two hypotheses: TLS-side delay vs TCP-side delay; need finer-grained tshark timing",
232
- "two hypotheses: server overload vs client retransmit storm; need a test pinning direction",
233
- "two hypotheses: DNS resolver failure vs network reachability to resolver; need a distinguishing test",
234
- "three hypotheses for periodic latency spikes: backup, GC pause, cron job; one tool query each",
235
- "two hypotheses about RST source (server-app vs middlebox); need a test",
236
- ],
237
- )
238
-
239
- OBSERVATION_NORMALIZER = TaskSpec(
240
- task_id="observation_normalizer",
241
- description="Convert raw tool output (tshark fields, zeek logs) into structured observations.",
242
- input_keys=["raw_output", "tool", "world_model"],
243
- output_fields=[
244
- FieldSpec("observations", "list_obj", min_items=1,
245
- item_keys=["id", "type", "flow_id", "time", "packet_ref"]),
246
- ],
247
- prompt_intro=(
248
- "you are a pcap observation normalizer. take raw tool output (e.g. tshark "
249
- "tab-separated fields) and produce structured observations. EVERY field "
250
- "(time, packet_ref, raw_fields) must come from the input — do not invent. "
251
- "preserve raw values; the agent loop relies on packet_ref being verifiable."
252
- ),
253
- seed_scenarios=[
254
- "tshark output with frame.time_epoch, ip.src, ip.dst, tcp.seq, tcp.ack for retransmissions",
255
- "tshark output with frame.number, tls.handshake.type for a TLS exchange",
256
- "zeek conn.log entries showing flow tuples + duration + bytes",
257
- "tshark output with dns.qry.name, dns.flags.response, dns.flags.rcode",
258
- "tshark output with tcp.analysis.flags marking RTOs, duplicate ACKs, retransmissions",
259
- "tshark output with tcp.window_size_value showing zero-window advertisements",
260
- "zeek dns.log with query / response pairs and timing",
261
- "tshark output with frame.time_relative and tcp.len for response gap analysis",
262
- ],
263
- )
264
-
265
- HYPOTHESIS_UPDATER = TaskSpec(
266
- task_id="hypothesis_updater",
267
- description="Update hypothesis status given new observation + prediction.",
268
- input_keys=["hypothesis", "prediction", "observation"],
269
- output_fields=[
270
- FieldSpec("hypothesis_updates", "list_obj", min_items=1,
271
- item_keys=["id", "status", "reason"],
272
- item_enums={"status": [
273
- "provisionally_supported", "weakened", "rejected",
274
- "confirmed", "inconclusive", "unchanged",
275
- ]}),
276
- ],
277
- prompt_intro=(
278
- "you are a pcap hypothesis updater. given a hypothesis with its prediction "
279
- "and a new observation, decide if observation **supports**, **weakens**, or "
280
- "**rejects** the hypothesis. CRITICAL: be willing to weaken/reject — do not "
281
- "pretend partial evidence is confirmation. `reason` must cite the specific "
282
- "observation field that drove the change."
283
- ),
284
- seed_scenarios=[
285
- "hyp: network-loss is cause; pred: retransmissions in delay window; obs: no retransmissions",
286
- "hyp: app-delay is cause; pred: tcp clean during gap; obs: tcp clean during gap",
287
- "hyp: client firewall blocks; pred: SYN goes out, no SYN-ACK; obs: SYN+SYN-ACK, no app data",
288
- "hyp: dns failing; pred: queries with no response; obs: response received but RCODE=SERVFAIL",
289
- "hyp: MTU blackhole; pred: small packets ok, large drop; obs: ALL sizes get through fine",
290
- "hyp: server overload; pred: response gap on most flows; obs: gap on only 5%",
291
- "hyp: tls negotiation issue; pred: hello-to-hello slow; obs: hello-to-hello fast, finished slow",
292
- "hyp: jitter from backup job; pred: spike pattern at scheduled times; obs: spike unrelated to schedule",
293
- ],
294
- )
295
-
296
- STOP_CONTROLLER = TaskSpec(
297
- task_id="stop_controller",
298
- description="Decide whether to continue diagnosis, stop with conclusion, or stop inconclusive.",
299
- input_keys=["current_hypotheses", "evidence_summary", "tools_used", "uncertainty"],
300
- output_fields=[
301
- FieldSpec("stop_decision", "enum",
302
- enum_values=["continue", "stop_with_conclusion", "stop_inconclusive"]),
303
- FieldSpec("reason", "str", freetext=True),
304
- FieldSpec("required_before_stop", "list", min_items=0), # may be empty if stop=stop_*
305
- ],
306
- prompt_intro=(
307
- "you are a pcap stop controller. given the diagnosis state, output ONE of: "
308
- "(a) continue if a clear next test would meaningfully narrow uncertainty, "
309
- "(b) stop_with_conclusion if remaining uncertainty is acceptable and one "
310
- "hypothesis dominates, (c) stop_inconclusive if available data cannot "
311
- "distinguish remaining hypotheses. do NOT keep digging when the data has "
312
- "been exhausted — over-digging produces false root causes. EQUALLY, do "
313
- "NOT conclude when one cheap test would meaningfully narrow uncertainty — "
314
- "premature conclusion produces false root causes too. When in doubt about "
315
- "whether enough evidence has been collected, default to stop_inconclusive "
316
- "rather than stop_with_conclusion: a hedged answer is better than a wrong one."
317
- ),
318
- seed_scenarios=[
319
- "two hyps still competing 60/40, one cheap remaining test would distinguish",
320
- "one hyp at 0.85 confidence, others at <0.1, available data exhausted",
321
- "all hyps still <0.5 confidence, pcap is one-sided, no more reachable evidence",
322
- "single hyp confirmed across 5 flows, no contradicting evidence",
323
- "two hyps equally weighted, all remaining tools would yield same evidence already seen",
324
- "one hyp supported on 3/10 sampled flows but rejected on 7/10 — likely incomplete",
325
- "loop: 3 actions ran, no hyp confidence has moved at all in last 2 rounds",
326
- "investigation has run 8 actions; cost ceiling reached; current state still ambiguous",
327
- ],
328
- )
329
-
330
-
331
- DIAG_TASKS: dict[str, TaskSpec] = {
332
- spec.task_id: spec
333
- for spec in [
334
- PROBLEM_FRAMING, WORLD_MODEL_BUILDER, SYMPTOM_DETECTOR, HYPOTHESIS_GENERATOR,
335
- TEST_PLANNER, OBSERVATION_NORMALIZER, HYPOTHESIS_UPDATER, STOP_CONTROLLER,
336
- ]
337
- }
338
-
339
-
340
- def all_task_ids() -> list[str]:
341
- return list(DIAG_TASKS.keys())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/diag/train_scenarios.py DELETED
@@ -1,341 +0,0 @@
1
- """Train-only scenario seeds for diag_sft_v1 SFT data generation.
2
-
3
- DISJOINT from the eval `seed_scenarios` in schemas.py. The split prevents
4
- the model from memorizing the exact eval prompts during SFT — a future
5
- diag_eval_v1 measurement on a model trained from these scenarios is then
6
- testing schema generalization rather than scenario recall.
7
-
8
- Pairing: `gen_diag_sft.py` reads from TRAIN_SCENARIOS, `gen_diag_eval_gt.py`
9
- reads from schemas.py:seed_scenarios. The 24-row `<task>_heldout.jsonl`
10
- remains the ultimate contamination gate (uses eval seeds, never trained on).
11
-
12
- Convention: **32 scenarios per task** (expanded from initial 16 on 2026-05-21
13
- to improve per-scenario diversity at 50k+ scale gen). Semantically distinct
14
- families from the 8 eval scenarios. Capagent will randomly sample with
15
- replacement at gen time, so total unique inputs per task scale to ~250-400
16
- with model temperature variation.
17
- """
18
- from __future__ import annotations
19
-
20
-
21
- TRAIN_SCENARIOS: dict[str, list[str]] = {
22
- "problem_framing": [
23
- # Original 16 (2026-05-20)
24
- "user reports TLS-terminated reverse proxy occasionally returns 502 to mobile clients only, has edge-pcap",
25
- "user observes WebSocket connections disconnect after exactly 60s of idle, has client-side pcap",
26
- "user notices a Kafka consumer group rebalance storm at midnight UTC daily, has tap pcap from broker side",
27
- "user reports SMTP submissions hang for 30s before completing, pcap from MTA edge available",
28
- "user sees gRPC unary RPCs timeout in p99 only, pcap from both client and server LB",
29
- "user complains BitTorrent-like long-lived connections die after firewall reload, edge pcap",
30
- "user notes IoT MQTT brokers see PINGRESP delays during firmware updates, broker-side pcap",
31
- "user reports VPN tunnel periodically drops single packets, capture from internal endpoint",
32
- "user sees QUIC fallback to TCP for a subset of clients behind one ISP, edge-pcap",
33
- "user observes Redis Sentinel failover taking 18s on a 3-node cluster, broker tap-port pcap",
34
- "user reports SNMP polling missing one MIB tree every other poll, no obvious pattern, mgmt vlan pcap",
35
- "user complains LDAP STARTTLS rejects from one client, pcap available from LDAP server side",
36
- "user observes BGP session flapping between two routers in same AS, both router-tap pcaps",
37
- "user reports RTSP stream pause for 8s every 15 min, pcap from camera side only",
38
- "user notes container egress traffic going to wrong NAT pool intermittently, node-host pcap",
39
- "user sees iSCSI initiator lose path during snapshot, target-side pcap available",
40
- # Expansion +16 (2026-05-21)
41
- "user notices Memcached connections leak file descriptors over 48h, no obvious failure, server-side pcap",
42
- "user reports IPSec tunnel rekey takes 12s causing data plane outage, both peers pcap",
43
- "user observes WebRTC video glitches when remote peer moves through cell handoff, mobile pcap",
44
- "user notes Envoy sidecar adds 30ms p99 latency only for inbound traffic, sidecar-host pcap",
45
- "user reports periodic Cassandra gossip storm causes node flapping, cluster-internal pcap",
46
- "user complains S3 multipart upload fails at part 3 of 10 reproducibly, client pcap",
47
- "user observes JMS message delivery skipping queue order during failover, broker tap",
48
- "user reports NTP offset drifts 200ms during business hours, NTP server-side pcap",
49
- "user notes Mongo replica set election takes 45s on a 5-node cluster, primary-side pcap",
50
- "user sees DoH (DNS-over-HTTPS) queries fail to one provider only, browser-host pcap",
51
- "user reports Modbus TCP slave responses delayed during high temperature, slave-side pcap",
52
- "user observes Tor circuit setup taking 12s on a specific exit selection, client pcap",
53
- "user notes Postgres logical replication slot lag growing during peak, subscriber pcap",
54
- "user reports SAP RFC calls fail with timeout to one application server, mtu pcap",
55
- "user complains video doorbell loses live feed after exact 60-min sessions, doorbell-side pcap",
56
- "user observes RDP session disconnects when bandwidth drops below 256kbps, edge pcap",
57
- ],
58
- "world_model_builder": [
59
- # Original 16
60
- "summary of dual-stack IPv4+IPv6 web traffic to same hostname, AAAA preferred but TCP-RST'd",
61
- "summary of MQTT-over-WSS flow from IoT gateway through L7 LB to broker cluster",
62
- "summary of TCP traffic showing PROXY-protocol prefix headers before TLS",
63
- "summary of QUIC v1 traffic with version negotiation between client and server",
64
- "summary of multipath TCP (MPTCP) flow with two subflows from same client",
65
- "summary of ICMP TimeExceeded responses interleaved with TCP application traffic",
66
- "summary of LACP-bundled traffic captured on one of the two member interfaces only",
67
- "summary of GRE-encapsulated TCP showing inner and outer 5-tuples both visible",
68
- "summary of SCTP multihomed association between two endpoints with two IPs each",
69
- "summary of VXLAN-encapsulated east-west traffic captured at the spine switch",
70
- "summary of TCP keepalive probes on a long-idle BGP session every 60s",
71
- "summary of TFTP UDP flow with port renegotiation after WRQ acknowledgment",
72
- "summary of PIM-SM multicast control plus IGMP joins from receivers",
73
- "summary of SCCP signaling between IP phone and call manager with frequent registrations",
74
- "summary of TCP flow with selective ACK options heavily used during loss recovery",
75
- "summary of WireGuard UDP traffic (no L7 visibility) between two peer endpoints",
76
- # Expansion +16
77
- "summary of HTTP/3 over QUIC v2 traffic with version negotiation between client and CDN edge",
78
- "summary of SRv6 (segment routing IPv6) ingress traffic with multiple SIDs in IPv6 header",
79
- "summary of MPLS L3VPN traffic showing label stack with three labels on transit packets",
80
- "summary of OSPFv3 IPv6 hello packets between two routers in same area",
81
- "summary of EVPN MAC advertisement BGP UPDATEs between two leaf switches",
82
- "summary of CoAP UDP requests over DTLS to multiple IoT devices behind same gateway",
83
- "summary of WebRTC ICE STUN binding requests during connection candidate gathering",
84
- "summary of mTLS-protected gRPC streaming bidi between sidecar proxies",
85
- "summary of EAP-TLS supplicant authentication on 802.1X-enabled switch port",
86
- "summary of BFD (bidirectional forwarding detection) control packets between two routers",
87
- "summary of NVMe-oF over RDMA traffic between initiator and target on RoCEv2",
88
- "summary of WebSocket connections to a chat backend with per-message DEFLATE compression",
89
- "summary of LACP control packet exchange between switch and server team interfaces",
90
- "summary of Profinet IO cyclic data exchange between PLC and field device",
91
- "summary of Tailscale WireGuard mesh with multiple peers on same magic DNS",
92
- "summary of SRTP-encrypted RTP from WebRTC video conf, no L7 visibility but timing patterns",
93
- ],
94
- "symptom_detector": [
95
- # Original 16
96
- "events showing 8 RTOs back-to-back within 1.5s on a single flow during file transfer",
97
- "tcp analysis showing SACK blocks growing across 12 packets without forward progress",
98
- "events showing TLS Alert level=fatal description=handshake_failure mid-handshake",
99
- "tcp analysis showing window-scaling factor of 0 used after expected 7 — degraded throughput",
100
- "events showing 200ms gap between server FIN and client FIN-ACK on closing handshake",
101
- "tcp analysis flagging out-of-order segments persisting 400ms before reorder buffer drains",
102
- "events showing HTTP/2 GOAWAY frame from server with error_code=ENHANCE_YOUR_CALM",
103
- "tcp analysis showing PUSH+FIN combined flag on a single segment from a server response",
104
- "events showing 3 consecutive ICMP fragmentation-needed messages followed by TCP stalls",
105
- "tcp analysis showing packets out of seq number range (likely packet injection or replay)",
106
- "events showing UDP datagrams arriving after 800ms+ delay (jitter) on a real-time flow",
107
- "tcp analysis showing keepalive probes failing 3x in a row before connection RST",
108
- "events showing TLS resumption attempt rejected with new full handshake every connection",
109
- "tcp analysis showing client window shrinking to 1024 mid-flow (window collapse)",
110
- "events showing 5 SYN retries with 1s/2s/4s/8s/16s spacing — classic SYN backoff",
111
- "tcp analysis showing simultaneous open (both sides send SYN nearly at once)",
112
- # Expansion +16
113
- "events showing TLS Alert level=warning description=user_canceled during handshake",
114
- "tcp analysis showing RST with non-zero ACK from middlebox not matching server side",
115
- "events showing HTTP/2 PING frame with payload mismatch between request and response",
116
- "tcp analysis showing 8 consecutive zero-window updates within 50ms (rapid window collapse)",
117
- "events showing IPv6 NDP redirect messages directing traffic away from default GW",
118
- "tcp analysis showing congestion window collapse to 1 MSS after 3 dup-ACKs",
119
- "events showing TLS ClientHello with no SNI extension from updated browser client",
120
- "tcp analysis flagging timestamp option (TSecr=0) after established connection (TS option dropped)",
121
- "events showing HTTP/2 GOAWAY with last_stream_id=0 right after settings exchange",
122
- "tcp analysis showing PSH+ACK alternating with FIN+ACK from same endpoint mid-flow",
123
- "events showing IPSec ESP sequence number wraparound triggering rekey",
124
- "tcp analysis showing SACK ranges overlap (likely buggy SACK implementation)",
125
- "events showing UDP checksum errors clustered around a specific interface ingress",
126
- "tcp analysis showing window scale = 14 used (max RFC value, suspicious memory)",
127
- "events showing HTTP/3 STREAM frame with FIN bit set on stream 0 (control stream)",
128
- "tcp analysis showing PAWS (protection against wrapped sequences) check failures",
129
- ],
130
- "hypothesis_generator": [
131
- # Original 16
132
- "symptom: HTTP/2 GOAWAY ENHANCE_YOUR_CALM after burst of 50 requests on one connection",
133
- "symptom: TLS resumption tickets rejected, full handshake every time on subset of clients",
134
- "symptom: SCTP heartbeat timeout on one of two paths in a multihomed association",
135
- "symptom: BGP UPDATE messages with malformed AS_PATH attribute appearing periodically",
136
- "symptom: MTU-related packet drops only on traffic transiting one specific WAN link",
137
- "symptom: QUIC handshake taking 3x normal RTT — possible amplification protection trigger",
138
- "symptom: PROXY protocol header missing on some connections to backend server",
139
- "symptom: HTTP/2 RST_STREAM stream-level cancellations spiking from one client",
140
- "symptom: NFS RPC retransmissions clustered around storage failover events",
141
- "symptom: TCP simultaneous open events appearing on internal east-west traffic",
142
- "symptom: TLS SNI mismatch between hostname in HTTP host header and TLS cert CN",
143
- "symptom: VXLAN inner-flow drops only when inner-MTU exceeds carrier-MTU minus encap",
144
- "symptom: HTTP/3 (QUIC) connections falling back to HTTP/2 for clients behind one ISP",
145
- "symptom: SSH client SSH-2.0 banner exchange completes but auth phase times out",
146
- "symptom: Redis MULTI/EXEC blocks taking 3s where 50ms is baseline",
147
- "symptom: TCP RST from server with TTL value different from established traffic",
148
- # Expansion +16
149
- "symptom: BGP UPDATE messages stuck behind one peer's TCP MSS issue",
150
- "symptom: TLS clients failing only when CDN edge serves session ticket from peer",
151
- "symptom: WebSocket connections fail to upgrade for traffic transiting one load balancer",
152
- "symptom: QUIC 0-RTT data being rejected by server even when ticket is valid",
153
- "symptom: NTP polling falls back to text protocol over UDP/123 unexpectedly",
154
- "symptom: gRPC bidi streams cancelled after 30s of idle by middle proxy",
155
- "symptom: MPLS-VPN packets dropped at PE router only for traffic with specific DSCP",
156
- "symptom: Kafka producer batches timing out when broker replica leader changes",
157
- "symptom: DTLS-protected SIP signaling failing for clients behind one CGNAT",
158
- "symptom: TCP ECN markings stripped on traffic transiting one ISP link",
159
- "symptom: HTTP/2 server push frames being rejected with REFUSED_STREAM",
160
- "symptom: SCTP path failure between two endpoints despite both interfaces UP",
161
- "symptom: Redis pub/sub messages reordered when client reconnects to different replica",
162
- "symptom: DNS-over-HTTPS responses delayed by 1.5s to specific resolver",
163
- "symptom: TCP zero-copy send failing on Linux 6.x with specific BPF program loaded",
164
- "symptom: TLS 1.3 EarlyData stream getting dropped at HTTP/2 ALPN negotiation",
165
- # Turn-N refinement scenarios (non-empty prior_hypotheses) — teach carry-over:
166
- "turn-N refinement. prior_hypotheses=[H1 server-app-bottleneck status=provisionally_supported, H2 network-congestion status=weakened]. new evidence: server CPU stayed <30% throughout the gap window. Refine: drop H1, keep H2 but downgrade further, add H3 about middlebox/inspection delay",
167
- "turn-N refinement. prior_hypotheses=[H1 TLS-handshake-misconfig, H2 firewall-rate-limit status=open]. new evidence: TLS handshake completes normally for the control flow. Refine: mark H1 weakened (keep id), keep H2, add nothing",
168
- "turn-N refinement. prior_hypotheses=[H1 path-MTU-blackhole status=provisionally_supported]. new evidence: small-packet ICMP pings now also failing intermittently. Refine: broaden H1's claim to 'path quality degraded across packet sizes', keep id H1",
169
- "turn-N refinement. prior_hypotheses=[H1 dns-resolver-failure status=rejected, H2 client-stub-misconfig status=provisionally_supported]. new evidence: another fallback resolver also fails. Refine: H1 stays dropped, keep H2, add H3 about client name-resolution policy or hosts file",
170
- "turn-N refinement. prior_hypotheses=[H1 server-app-bottleneck status=confirmed, H2 network-congestion status=rejected, H3 client-side status=rejected]. new evidence: no contradiction in 3 follow-up tests. Refine: keep H1 only as the surviving hypothesis (chain ready for stop_with_conclusion)",
171
- "turn-N refinement. prior_hypotheses=[H1 inconclusive, H2 inconclusive, H3 inconclusive]. new evidence: a 4th retransmission burst on a brand new flow with same client subnet. Refine: tighten the strongest existing claim to be subnet-specific; do NOT add yet another hypothesis",
172
- "turn-N refinement. prior_hypotheses=[H1 server-TLS-stack-bug status=weakened, H2 client-TLS-stack-bug status=provisionally_supported]. new evidence: ServerHello timing now within 20ms across all flows. Refine: drop H1 (now fully contradicted), keep H2, add H3 about TLS extension handling",
173
- "turn-N refinement. prior_hypotheses=[H1 MTU-blackhole status=provisionally_supported]. new evidence: large packets now succeed across multiple flows after middlebox MTU was raised. Refine: H1 status promotes towards confirmed; keep id; chain ready to stop_with_conclusion next turn",
174
- "turn-N refinement. prior_hypotheses=[H1 DNS-cache-poisoning, H2 client-resolver-misconfig]. new evidence: dig from different resolver returns expected IP. Refine: keep H1 (still possible), mark H2 weakened, add H3 about authoritative-server inconsistency",
175
- "turn-N refinement. prior_hypotheses=[H1 firewall-state-table-eviction, H2 server-explicit-close]. new evidence: server logs show no application-level close, RSTs arrive even during active data transfer. Refine: tighten H1 to specify middlebox; mark H2 weakened",
176
- "turn-N refinement. prior_hypotheses=[H1 server-side-thread-starvation status=open]. new evidence: only this one hypothesis remains plausible and is supported by 4 independent observations. Refine: confirm H1 (status=confirmed), keep id, downstream should fire stop_with_conclusion",
177
- "turn-N refinement. prior_hypotheses=[H1, H2 both inconclusive after 2 turns]. new evidence: a third tool run also gave ambiguous output that matches both H1 and H2 equally. Refine: keep both hypotheses unchanged but log that data exhaustion is approaching — stop_inconclusive is becoming the right next step",
178
- "turn-N refinement. prior_hypotheses=[H1 IPsec-MTU status=weakened, H2 carrier-fragmentation status=provisionally_supported]. new evidence: large packets fail even on flows that don't traverse the IPsec tunnel. Refine: drop H1 (now contradicted), keep H2, add H3 about endpoint NIC offload bug",
179
- "turn-N refinement. prior_hypotheses=[H1, H2, H3]. new evidence: a measurement error was found in the earlier observation that supported H3. Refine: revert H3 to status=open, do not drop or confirm; keep H1 and H2",
180
- "turn-N refinement. prior_hypotheses=[H1 application-thread-pool-exhaustion status=provisionally_supported]. new evidence: thread-pool metrics from server actually look healthy, but a new symptom of GC pause spikes appeared. Refine: weaken H1, add H4 about garbage collector pauses correlating with the gap windows",
181
- "turn-N refinement. prior_hypotheses=[H1 client-side-buffer-overflow]. new evidence: client buffer metrics look fine but a packet shows zero-window from server. Refine: weaken H1, add H2 about server-side flow control / receive buffer pressure",
182
- ],
183
- "test_planner": [
184
- # Original 16
185
- "two hypotheses: middlebox bookmark-stripping vs server-app reject; need test to pin layer",
186
- "two hypotheses: TLS resumption ticket key rotation vs cache eviction; need test inside server logs vs network",
187
- "three hypotheses for slow first-byte: dns, tcp_handshake, tls_handshake; one tshark filter each",
188
- "two hypotheses: MTU blackhole inside tunnel vs end-host MSS clamping issue; need a test",
189
- "two hypotheses for spurious RSTs: middlebox state-table eviction vs server-app explicit close; need test",
190
- "two hypotheses: client SACK disabled vs server SACK disabled; need a test using one direction's pcap only",
191
- "two hypotheses: tcp window-scale option dropped by middlebox vs misconfigured client; need test",
192
- "three hypotheses for jitter: cross-traffic, hardware queue, polling-cycle; one test each",
193
- "two hypotheses: NAT entry expiration vs ECMP rehash mid-flow; need a test",
194
- "two hypotheses for one-way audio: codec mismatch vs return-path drop; need a test",
195
- "two hypotheses for HTTP/2 stalls: flow-control credit exhaustion vs HEADERS frame fragmentation; need test",
196
- "two hypotheses for VPN drop: rekey storm vs MTU change; need test using only IPsec headers",
197
- "three hypotheses for periodic latency: ARP refresh, GC pause, IPC ring full; one test each",
198
- "two hypotheses for TLS slow handshake: OCSP stapling fetch vs cert chain depth; need test",
199
- "two hypotheses: BGP convergence vs route reflector delay; need a passively-observable test",
200
- "two hypotheses for HTTP 503: backend timeout vs LB health-check flap; need test using pcap",
201
- # Expansion +16
202
- "two hypotheses: TLS 1.3 0-RTT replay vs ticket binding mismatch; need test using single client capture",
203
- "three hypotheses for IPv6 hop limit drops: ICMP rate limit, transit RA prefix mismatch, MTU; one test each",
204
- "two hypotheses: HTTP/3 PADDING frame causing stream stall vs HEADER frame fragmentation; need test",
205
- "two hypotheses for SCTP heartbeat fail: path MTU vs NAT pinhole; need test using primary path only",
206
- "three hypotheses for slow CoAP responses: server retransmit, gateway buffering, DTLS handshake; one test each",
207
- "two hypotheses: TCP timestamps PAWS reject vs sequence number wrap; need test using TS option",
208
- "two hypotheses for missing Multicast: IGMP membership not joined vs PIM-SM prune; need test",
209
- "three hypotheses for HTTP/2 stall: SETTINGS_MAX_CONCURRENT_STREAMS, flow control, GOAWAY; one test each",
210
- "two hypotheses: BGP convergence vs route advertisement filter; need test using AS_PATH attribute",
211
- "two hypotheses for failed WebRTC: ICE failure vs DTLS-SRTP key exchange; need test using STUN binding stats",
212
- "three hypotheses for NFS slow: rpc.statd timeout, client cache stale, server queue full; one test each",
213
- "two hypotheses: WireGuard MTU vs key rotation drift; need test using handshake_init counter",
214
- "two hypotheses: NTP DDoS amplification block vs ratelimit; need test using mode 6/7 packets",
215
- "two hypotheses for RTSP pause: codec switch vs keyframe miss; need test using NAL unit type",
216
- "three hypotheses for OAuth2 fail: token-endpoint TLS, JWT signature, scope mismatch; one test each",
217
- "two hypotheses for QUIC fallback: UDP block at LB vs ALPN advertise of h3-only; need test using SettingsParameters",
218
- ],
219
- "observation_normalizer": [
220
- # Original 16
221
- "tshark output with sip.method, sip.cseq for SIP INVITE/100/180/200 dialog",
222
- "tshark output with diameter.cmd.code, diameter.flags showing Cer/Cea exchange",
223
- "tshark output with rtp.ssrc, rtp.seq, rtp.timestamp, rtp.marker for an RTP audio stream",
224
- "tshark output with smb2.cmd, smb2.tree.id for SMB session establishment",
225
- "tshark output with kerberos.msg_type, kerberos.realm for AS-REQ/AS-REP exchange",
226
- "tshark output with ntp.stratum, ntp.refid, ntp.precision for NTP client polling",
227
- "tshark output with ldap.protocolOp showing bindRequest and searchResultEntry",
228
- "tshark output with snmp.opcode, snmp.varbind for GET/RESPONSE pairs",
229
- "zeek http.log with method, uri, status_code, response_body_len fields",
230
- "zeek ssl.log with version, cipher, server_name, validation_status",
231
- "zeek dhcp.log with msg_types and DHCP option values for lease acquisition",
232
- "zeek ssh.log with auth_attempts and auth_success fields",
233
- "tshark output with quic.frame.type, quic.connection.id for a QUIC initial packet",
234
- "tshark output with mqtt.msgtype, mqtt.topic, mqtt.qos for a publish exchange",
235
- "tshark output with bgp.type, bgp.path_attr.as_path for an UPDATE message",
236
- "tshark output with rpcrdma.opcode for RDMA-over-TCP NFS traffic",
237
- # Expansion +16
238
- "tshark output with diameter.session.id, diameter.origin.host for Gx session establishment",
239
- "tshark output with mip6.type for IPv6 mobile node binding update exchange",
240
- "tshark output with srv6.sids for ingress SR header processing",
241
- "tshark output with isakmp.spi for IKE_SA_INIT phase one",
242
- "zeek dns.log entries with rcode_name, query_type, ttl for client resolver behavior",
243
- "zeek conn.log entries with history field showing TCP state transitions",
244
- "tshark output with quic.frame.type for ACK_ECN counting paths",
245
- "tshark output with sctp.heartbeat.info for SCTP path monitoring",
246
- "zeek modbus.log entries with unit_id, function for industrial control queries",
247
- "tshark output with stp.bridgepriority for spanning tree root election",
248
- "tshark output with srtp.encrypted_payload for SRTP-protected RTP media",
249
- "tshark output with ssh.message_code for SSH transport layer key exchange",
250
- "tshark output with snmp.engineid for SNMPv3 USM authentication",
251
- "tshark output with l2tp.tunnel.id for L2TP control message header",
252
- "zeek rdp.log entries with cookie, security, keyboard_layout for RDP session start",
253
- "tshark output with cassandra.opcode for CQL native protocol startup",
254
- ],
255
- "hypothesis_updater": [
256
- # Original 16
257
- "hyp: HTTP/2 flow-control starvation; pred: WINDOW_UPDATE frames absent; obs: WINDOW_UPDATE frames present every 1MB",
258
- "hyp: MTU clamping issue; pred: TCP MSS option < 1460; obs: MSS = 1460 in SYN",
259
- "hyp: TLS cert chain too deep; pred: handshake size > 8KB; obs: handshake 3.2KB",
260
- "hyp: SACK disabled; pred: no SACK blocks in retransmits; obs: SACK blocks present",
261
- "hyp: BGP route reflector delay; pred: UPDATE arrives 30s+ after rr-client; obs: UPDATE within 200ms",
262
- "hyp: kerberos pre-auth required; pred: AS-REQ rejected with KDC_ERR_PREAUTH_REQUIRED; obs: AS-REP returned directly",
263
- "hyp: middlebox closes idle flows; pred: RST from non-server TTL; obs: RST from server TTL",
264
- "hyp: QUIC amplification protection limit; pred: server caps initial packets to 3x client; obs: 5x observed",
265
- "hyp: DNS resolver returns SERVFAIL; pred: RCODE=2 on every query; obs: RCODE=0 for some, SERVFAIL for half",
266
- "hyp: TLS resumption ticket reuse; pred: psk_identity present; obs: psk_identity absent, full handshake",
267
- "hyp: HTTP/3 fallback due to udp port block; pred: no QUIC initial packets observed; obs: QUIC initial present, no Handshake",
268
- "hyp: ipsec rekey storm; pred: IKE INFORMATIONAL messages every 10s; obs: every 600s as configured",
269
- "hyp: NFS retransmit due to storage failover; pred: cluster around failover events; obs: uniform distribution",
270
- "hyp: GC pause causing 200ms gap; pred: gap aligned with sawtooth memory pattern; obs: gap not periodic",
271
- "hyp: dual-stack AAAA blocked; pred: A query succeeds and AAAA times out; obs: both A and AAAA receive responses",
272
- "hyp: SMB2 session expiration; pred: TREE_DISCONNECT then TREE_CONNECT after timeout; obs: continuous session reuse",
273
- # Expansion +16
274
- "hyp: HTTP/2 SETTINGS_INITIAL_WINDOW_SIZE too small; pred: WINDOW_UPDATE within 1KB; obs: 64KB initial window present",
275
- "hyp: TCP timestamps disabled at one endpoint; pred: TS option absent in SYN; obs: TS option present both directions",
276
- "hyp: TLS post-handshake auth requested; pred: CertificateRequest after Finished; obs: no post-handshake messages",
277
- "hyp: BGP convergence delayed by route refresh; pred: ROUTE-REFRESH message present; obs: no refresh, gradual UPDATE",
278
- "hyp: SCTP heartbeat interval too long; pred: HEARTBEAT every 30s+; obs: HEARTBEAT every 30s as RFC default",
279
- "hyp: HTTP/3 0-RTT replay attack mitigation triggers; pred: server rejects with REJECT; obs: REJECT not present",
280
- "hyp: MTU clamping by middlebox; pred: SYN MSS rewritten; obs: SYN MSS unchanged from origin",
281
- "hyp: NFS retransmit due to slow disk; pred: rpc.duplicate calls every 5s+; obs: duplicate calls within 200ms (network)",
282
- "hyp: WireGuard handshake retry due to PSK mismatch; pred: handshake initiation repeated 3x within 5s; obs: only 1 retry",
283
- "hyp: TLS HelloRetryRequest needed; pred: server sends HRR; obs: server sends ServerHello directly",
284
- "hyp: GRE keepalive timeout; pred: tunnel state shows expired; obs: keepalive packets ack'd every 10s",
285
- "hyp: SIP REGISTER refresh failure; pred: 401 challenge with stale nonce; obs: 200 OK returned directly",
286
- "hyp: QUIC congestion control collapse; pred: congestion window drops to 1 MSS; obs: cwnd stays at IW10 throughout",
287
- "hyp: DTLS rekey storm; pred: ClientHello every 60s+; obs: only one ClientHello in 10 minutes",
288
- "hyp: RIP route poisoning event; pred: metric=16 advertised; obs: metric=15 used, route still valid",
289
- "hyp: Modbus exception response; pred: function code with high bit set; obs: normal response code returned",
290
- ],
291
- "stop_controller": [
292
- # Original 16
293
- "five hyps, all under 0.3 confidence, three uninvestigated remaining tests are cheap and informative",
294
- "single hyp confirmed across 20 sampled flows with 3 contradicting flows that look like noise",
295
- "two hyps still at 50/50, all remaining tests would yield observations we have already seen",
296
- "investigation 14 actions in, every action moved no hyp by more than 0.05 — stuck",
297
- "one hyp at 0.92, one at 0.05 — clear winner; only 2 of 50 tools used",
298
- "evidence saturated: all flows captured one-sided, root cause requires server-side data we don't have",
299
- "loop: 2 actions ran, both confirmed the dominant hyp; still in early phase, can dig more",
300
- "investigation cost at 80% of budget, leading hyp at 0.7, other 0.2; budget low for one more test",
301
- "all hyps consistent with available data; the only distinguishing test requires firewall reset which is out of scope",
302
- "evidence partially supports two hyps each from different angles — possible joint cause; cannot separate",
303
- "single hyp at 0.95 with 1 contradicting observation; needs explanation before stop_with_conclusion",
304
- "three hyps in cluster of 0.4/0.3/0.3; no single test can distinguish all three at once",
305
- "hyp confidence movements pre vs post last 4 tests are < 0.01 — diminishing returns regime",
306
- "investigation running 30 min wall (vs 5 min typical); state is ambiguous; user prefers timely partial answer",
307
- "evidence quality questionable: pcap shows packet capture drops > 0.5%; results unreliable for confirmation",
308
- "one hyp confirmed strongly on aggregate flow stats but lacks any per-packet evidence — confirm or dig deeper?",
309
- # Expansion +16
310
- "four hyps with confidences 0.3/0.25/0.25/0.2 — no clear winner, only 30% of tools used",
311
- "all hyps have been tested with at least 3 actions; all results in noise floor, decision required",
312
- "single hyp at 0.85, but evidence comes from a single capture point that may have bias",
313
- "user explicitly asks for partial answer due to time pressure; current top hyp at 0.65",
314
- "cluster of 2 hyps both at 0.45 are mutually exclusive but no test would distinguish them",
315
- "investigation already consumed 90% of allocated time; no clear winner; cheapest 3 tests run",
316
- "three hyps explored, two confirmed by independent flows — possibly composite root cause",
317
- "dominant hyp explained 12/15 observations; 3 unexplained observations cluster on one IP",
318
- "all 5 hyps are alternative framings of 'TLS handshake timeout'; need to refine before stop",
319
- "evidence quality high but interpretation depends on whether capture point has loss",
320
- "confidence on top hyp moved 0.4→0.7 over last 5 tests; trajectory predicts more lift if continued",
321
- "evidence supports complex chain of two events; can stop on the proximate cause and hand off root",
322
- "dominant hyp would imply server-side bug, but we lack capability to verify in server logs",
323
- "all hyps at low confidence (<0.4) but symptom is critical (production outage); need at least partial answer",
324
- "one hyp at 0.9 with 1 contradicting flow that is structurally different from others — outlier or evidence?",
325
- "investigation found no support for original 3 hyps; new hyp emerged at 0.55 — confirm or check baseline first",
326
- ],
327
- }
328
-
329
-
330
- def all_train_task_ids() -> list[str]:
331
- return list(TRAIN_SCENARIOS.keys())
332
-
333
-
334
- def scenarios_for(task_id: str, scenario_list: str = "train") -> list[str]:
335
- """Return scenarios for a task. scenario_list='train' or 'eval'."""
336
- if scenario_list == "train":
337
- return TRAIN_SCENARIOS[task_id]
338
- if scenario_list == "eval":
339
- from .schemas import DIAG_TASKS
340
- return DIAG_TASKS[task_id].seed_scenarios
341
- raise ValueError(f"scenario_list must be 'train' or 'eval', got {scenario_list!r}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/nanoai/agentic/eval_loop.py DELETED
@@ -1,332 +0,0 @@
1
- """Multi-turn agent eval state machine.
2
-
3
- For each task: generate → parse → execute tool → wrap result → repeat.
4
- Stops when assistant emits `<|assistant_end|>` without a tool call, or when
5
- turn budget is exceeded, or when the model emits malformed output twice.
6
- """
7
- from __future__ import annotations
8
-
9
- import re
10
- import time
11
- from typing import Optional
12
-
13
- from .data.common import SYSTEM_PROMPT
14
- from .eval_sandbox import Sandbox, SandboxError
15
- from .eval_tasks import Task, Transcript
16
-
17
-
18
- # Required arg keys per tool. Original Read/Edit/Grep/Bash come from
19
- # scripts/agent_eval.py; the lowercase ones come from ds79c (TraceForge
20
- # id=90) — same trajectories used by exp307a/b/c/324. Tools that exist
21
- # only as no-op stubs (write_todos, task, …) accept any args.
22
- TOOL_REQUIRED_ARGS: dict[str, set[str]] = {
23
- # Original nanoai tool surface
24
- "Read": {"file_path"},
25
- "Edit": {"file_path", "new_string"},
26
- "Grep": {"pattern"},
27
- "Bash": {"command"},
28
- # ds79c/demycap tool surface (lowercase names)
29
- "read_file": {"file_path"},
30
- "edit_file": {"file_path"}, # new_string optional (write_file behaviour for empty)
31
- "write_file": {"file_path"}, # content optional, model often forgets
32
- "execute": {"command"},
33
- "ls": set(),
34
- "glob": {"pattern"},
35
- "grep": {"pattern"},
36
- "save_mermaid": {"file_path"},
37
- "submit_report": {"report_markdown"}, # confidence is optional
38
- "write_todos": set(),
39
- "update_todos": set(),
40
- "update_todo": set(),
41
- "task": set(),
42
- }
43
-
44
- # Tools whose return value should terminate the multi-turn loop. The
45
- # only one today is `submit_report`; the call captures the payload on
46
- # the sandbox and the loop exits without sampling another turn.
47
- TERMINAL_TOOLS = frozenset({"submit_report"})
48
-
49
-
50
- def _parse_tool_call(body: str) -> tuple[str, dict[str, str]]:
51
- """Parse `tool_name <|tool_arg|> k <|tool_val|> v ...` into (name, args)."""
52
- parts = body.split("<|tool_arg|>")
53
- name = parts[0].strip()
54
- args: dict[str, str] = {}
55
- for part in parts[1:]:
56
- if "<|tool_val|>" in part:
57
- k, v = part.split("<|tool_val|>", 1)
58
- args[k.strip()] = v.strip()
59
- return name, args
60
-
61
-
62
- def _coerce_float(s: str, default: float = 0.0) -> float:
63
- try:
64
- return float(s)
65
- except (TypeError, ValueError):
66
- return default
67
-
68
-
69
- def _coerce_int(s: str, default: int = 0) -> int:
70
- try:
71
- return int(s)
72
- except (TypeError, ValueError):
73
- return default
74
-
75
-
76
- def _execute(sb: Sandbox, name: str, args: dict[str, str]) -> tuple[str, bool]:
77
- """Run a parsed tool call. Returns (result_text, ok)."""
78
- required = TOOL_REQUIRED_ARGS.get(name)
79
- if required is None:
80
- return f"unknown tool: {name!r}", False
81
- missing = required - args.keys()
82
- if missing:
83
- return f"missing required arg(s) for {name}: {sorted(missing)}", False
84
- try:
85
- # Original nanoai surface
86
- if name == "Read":
87
- return sb.read(args["file_path"],
88
- offset=_coerce_int(args.get("offset", "")),
89
- limit=_coerce_int(args.get("limit", ""), 50)), True
90
- if name == "Edit":
91
- return sb.edit(args["file_path"], args["new_string"], args.get("old_string", "")), True
92
- if name == "Grep":
93
- return sb.grep(args["pattern"], args.get("path") or "."), True
94
- if name == "Bash":
95
- return sb.bash(args["command"]), True
96
- # ds79c/demycap surface
97
- if name == "read_file":
98
- return sb.read_file(args["file_path"],
99
- offset=_coerce_int(args.get("offset", "")),
100
- limit=_coerce_int(args.get("limit", ""), 50)), True
101
- if name == "edit_file":
102
- return sb.edit_file(args["file_path"],
103
- new_string=args.get("new_string", ""),
104
- old_string=args.get("old_string", "")), True
105
- if name == "write_file":
106
- return sb.write_file(args["file_path"], args.get("content", "")), True
107
- if name == "execute":
108
- return sb.execute(args["command"]), True
109
- if name == "ls":
110
- return sb.ls_path(args.get("path", ".")), True
111
- if name == "glob":
112
- return sb.glob(args.get("pattern", "*"), args.get("path", ".")), True
113
- if name == "grep":
114
- return sb.grep(args["pattern"], args.get("path") or "."), True
115
- if name == "save_mermaid":
116
- return sb.save_mermaid(args["file_path"], args.get("content", "")), True
117
- if name == "submit_report":
118
- return sb.submit_report(
119
- report_markdown=args.get("report_markdown", ""),
120
- confidence=_coerce_float(args.get("confidence", "")),
121
- ), True
122
- if name == "write_todos":
123
- return sb.write_todos(), True
124
- if name == "update_todos":
125
- return sb.update_todos(), True
126
- if name == "update_todo":
127
- return sb.update_todo(**{k: v for k, v in args.items()}), True
128
- if name == "task":
129
- return sb.task(description=args.get("description", "")), True
130
- except SandboxError as e:
131
- return f"tool error: {e}", False
132
- except Exception as e:
133
- return f"tool crash: {type(e).__name__}: {e}", False
134
- return "internal: unreachable", False
135
-
136
-
137
- def _build_initial_tokens(tokenizer, user_text: str, system_prompt: Optional[str] = None) -> list[int]:
138
- # Adapter path (e.g. QwenTokenizer) — delegate to tokenizer's own chat template.
139
- if hasattr(tokenizer, "build_initial_tokens"):
140
- sp = SYSTEM_PROMPT if system_prompt is None else system_prompt
141
- return tokenizer.build_initial_tokens(user_text, system_prompt=sp)
142
- bos = tokenizer.get_bos_token_id()
143
- user_start = tokenizer.encode_special("<|user_start|>")
144
- user_end = tokenizer.encode_special("<|user_end|>")
145
- asst_start = tokenizer.encode_special("<|assistant_start|>")
146
- sp = SYSTEM_PROMPT if system_prompt is None else system_prompt
147
- body = sp + "\n\n" + user_text
148
- return [bos, user_start] + tokenizer.encode(body) + [user_end, asst_start]
149
-
150
-
151
- def _append_tool_result(tokenizer, tokens: list[int], result_text: str) -> None:
152
- """Append `<|tool_result_start|>{result}<|tool_result_end|><|assistant_start|>`."""
153
- if hasattr(tokenizer, "append_tool_result"):
154
- tokenizer.append_tool_result(tokens, result_text)
155
- return
156
- tokens.append(tokenizer.encode_special("<|tool_result_start|>"))
157
- # Truncate result to keep KV cache usage bounded.
158
- if len(result_text) > 4000:
159
- result_text = result_text[:4000] + "\n...[truncated]"
160
- tokens.extend(tokenizer.encode(result_text))
161
- tokens.append(tokenizer.encode_special("<|tool_result_end|>"))
162
- tokens.append(tokenizer.encode_special("<|assistant_start|>"))
163
-
164
-
165
- def _assistant_end_id(tokenizer):
166
- if hasattr(tokenizer, "assistant_end_id"):
167
- return tokenizer.assistant_end_id()
168
- return tokenizer.encode_special("<|assistant_end|>")
169
-
170
-
171
- def _tool_call_pair(tokenizer):
172
- if hasattr(tokenizer, "tool_call_token_pair"):
173
- return tokenizer.tool_call_token_pair()
174
- return (
175
- tokenizer.encode_special("<|tool_call_start|>"),
176
- tokenizer.encode_special("<|tool_call_end|>"),
177
- )
178
-
179
-
180
- def _parse_tool_call_dispatch(tokenizer, body_text: str):
181
- """Dispatch to tokenizer-specific body parser (Qwen JSON vs nanoai k/v)."""
182
- if hasattr(tokenizer, "parse_tool_body"):
183
- return tokenizer.parse_tool_body(body_text)
184
- return _parse_tool_call(body_text)
185
-
186
-
187
- def _generate_one_turn(engine, tokenizer, prompt_tokens: list[int],
188
- max_tokens: int, temperature: float, seed: int,
189
- capture_logprobs: bool = False):
190
- """Run engine.generate until <|assistant_end|> or max_tokens.
191
-
192
- Returns the list of generated token ids (excluding the prompt). When
193
- capture_logprobs=True, returns (out_ids, out_logprobs) where out_logprobs[i]
194
- is the behavior-policy log prob of out_ids[i] (None for any forced token) —
195
- used to store behavior logprobs for TIS (Polar mechanism)."""
196
- asst_end = _assistant_end_id(tokenizer)
197
- bos = tokenizer.get_bos_token_id()
198
- out_ids: list[int] = []
199
- # Only pass return_logprobs when capturing, so engines/fakes that don't
200
- # accept the kwarg keep working unchanged (backward compatible).
201
- if capture_logprobs:
202
- out_logprobs: list = []
203
- gen = engine.generate(prompt_tokens, num_samples=1, max_tokens=max_tokens,
204
- temperature=temperature, seed=seed, return_logprobs=True)
205
- for token_column, _masks, logprob_column in gen:
206
- out_logprobs.append(logprob_column[0])
207
- tok = token_column[0]
208
- out_ids.append(tok)
209
- if tok == asst_end or tok == bos:
210
- break
211
- return out_ids, out_logprobs
212
- for token_column, _masks in engine.generate(
213
- prompt_tokens,
214
- num_samples=1,
215
- max_tokens=max_tokens,
216
- temperature=temperature,
217
- seed=seed,
218
- ):
219
- tok = token_column[0]
220
- out_ids.append(tok)
221
- if tok == asst_end or tok == bos:
222
- break
223
- return out_ids
224
-
225
-
226
- def run_task(
227
- engine,
228
- tokenizer,
229
- sandbox: Sandbox,
230
- task: Task,
231
- *,
232
- max_tokens_per_turn: int = 256,
233
- max_turns: Optional[int] = None,
234
- temperature: float = 0.5,
235
- seed: int = 42,
236
- return_transcript: bool = False,
237
- ) -> dict:
238
- """Run one task end-to-end. Returns a dict with pass/fail + diagnostics.
239
-
240
- When return_transcript=True the dict also includes:
241
- - 'user_prompt': str
242
- - 'transcript': list[dict] (every assistant content + tool_call(name+args) +
243
- tool_result text + error flag, in order)
244
- """
245
- max_turns = max_turns or task.max_turns
246
- transcript = Transcript(user_prompt=task.prompt)
247
- task.setup(sandbox)
248
-
249
- asst_end = _assistant_end_id(tokenizer)
250
- tc_start, tc_end = _tool_call_pair(tokenizer)
251
-
252
- tokens = _build_initial_tokens(tokenizer, task.prompt,
253
- system_prompt=getattr(task, "system_prompt", None))
254
- n_turns = 0
255
- n_tool_errors = 0
256
- t0 = time.time()
257
- truncated = False
258
-
259
- while n_turns < max_turns:
260
- n_turns += 1
261
- out_ids = _generate_one_turn(engine, tokenizer, tokens,
262
- max_tokens_per_turn, temperature, seed + n_turns)
263
- # Trim trailing assistant_end if present.
264
- ended_clean = bool(out_ids) and out_ids[-1] == asst_end
265
- body_ids = out_ids[:-1] if ended_clean else out_ids
266
- if not ended_clean and len(out_ids) >= max_tokens_per_turn:
267
- truncated = True
268
-
269
- # Inspect for tool call.
270
- if tc_start in body_ids and tc_end in body_ids:
271
- i = body_ids.index(tc_start)
272
- try:
273
- j = body_ids.index(tc_end, i + 1)
274
- except ValueError:
275
- j = -1
276
- if j > i:
277
- pre = tokenizer.decode(body_ids[:i]).strip()
278
- tool_body = tokenizer.decode(body_ids[i + 1 : j])
279
- try:
280
- name, args = _parse_tool_call_dispatch(tokenizer, tool_body)
281
- except ValueError as e:
282
- # Malformed tool body — surface as a tool error, no execute.
283
- name, args = "<parse_error>", {}
284
- result_text, ok = f"tool parse error: {e}", False
285
- else:
286
- result_text, ok = _execute(sandbox, name, args)
287
- transcript.turns.append({
288
- "role": "assistant",
289
- "content": pre,
290
- "tool_call": {"name": name, "args": args},
291
- })
292
- transcript.turns.append({
293
- "role": "tool_result",
294
- "tool_result": result_text,
295
- "error": not ok,
296
- })
297
- if not ok:
298
- n_tool_errors += 1
299
- # Terminating tool (submit_report) → exit loop; no need
300
- # to extend context for another turn.
301
- if name in TERMINAL_TOOLS:
302
- break
303
- # Extend context with the assistant turn we just generated +
304
- # tool result + a fresh assistant_start.
305
- tokens.extend(out_ids) # includes asst_end if present
306
- if not ended_clean:
307
- tokens.append(asst_end)
308
- _append_tool_result(tokenizer, tokens, result_text)
309
- continue
310
-
311
- # No tool call this turn → final answer.
312
- text = tokenizer.decode(body_ids).strip()
313
- transcript.turns.append({"role": "assistant", "content": text})
314
- break
315
-
316
- elapsed = time.time() - t0
317
- passed, reason = task.check(sandbox, transcript)
318
- out = {
319
- "task_id": task.task_id,
320
- "passed": passed,
321
- "reason": reason,
322
- "n_turns": n_turns,
323
- "n_tool_errors": n_tool_errors,
324
- "truncated": truncated,
325
- "elapsed_s": round(elapsed, 2),
326
- "tools_used": [c.get("name", "") for c in transcript.tool_calls],
327
- "last_text": transcript.last_assistant_text[:200],
328
- }
329
- if return_transcript:
330
- out["user_prompt"] = transcript.user_prompt
331
- out["transcript"] = transcript.turns
332
- return out