Text Generation
Transformers
Safetensors
ravenguard
guardrail
content-safety
moderation
multilingual
cross-lingual
multilingual-safety
agent-safety
custom_code
Instructions to use netis-ai/RavenGuard-gen with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use netis-ai/RavenGuard-gen with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="netis-ai/RavenGuard-gen", trust_remote_code=True)# Load model directly from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("netis-ai/RavenGuard-gen", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use netis-ai/RavenGuard-gen with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "netis-ai/RavenGuard-gen" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "netis-ai/RavenGuard-gen", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/netis-ai/RavenGuard-gen
- SGLang
How to use netis-ai/RavenGuard-gen with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "netis-ai/RavenGuard-gen" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "netis-ai/RavenGuard-gen", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "netis-ai/RavenGuard-gen" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "netis-ai/RavenGuard-gen", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Docker Model Runner
How to use netis-ai/RavenGuard-gen with Docker Model Runner:
docker model run hf.co/netis-ai/RavenGuard-gen
Self-contained backbone (netis_amniota); remove nanoai dependency & naming (forward bit-identical)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- configuration_ravenguard.py +3 -3
- modeling_ravenguard.py +28 -25
- netis_amniota/__init__.py +6 -0
- netis_amniota/agentic/__init__.py +1 -0
- {vendor/nanoai → netis_amniota}/agentic/tokenizer.py +7 -7
- {vendor/nanoai → netis_amniota}/agentic/tokenizer_qwen.py +4 -4
- {vendor/nanoai → netis_amniota}/checkpoint_manager.py +5 -5
- {vendor/nanoai → netis_amniota}/common.py +16 -16
- {vendor/nanoai → netis_amniota}/flash_attention.py +4 -4
- {vendor/nanoai → netis_amniota}/gpt.py +9 -9
- {vendor/nanoai → netis_amniota}/model_config.py +10 -10
- {vendor/nanoai → netis_amniota}/msa/__init__.py +5 -5
- {vendor/nanoai → netis_amniota}/msa/attention.py +0 -0
- {vendor/nanoai → netis_amniota}/msa/fa4.py +2 -2
- {vendor/nanoai → netis_amniota}/msa/runtime.py +2 -2
- {vendor/nanoai → netis_amniota}/nvfp4.py +6 -6
- {vendor/nanoai → netis_amniota}/optim.py +1 -1
- {vendor/nanoai → netis_amniota}/sparse_attention.py +1 -1
- {vendor/nanoai → netis_amniota}/tokenizer.py +6 -6
- tokenization_ravenguard.py +1 -1
- vendor/nanoai/__init__.py +0 -20
- vendor/nanoai/agentic/README.md +0 -68
- vendor/nanoai/agentic/__init__.py +0 -7
- vendor/nanoai/agentic/code_dataloader.py +0 -407
- vendor/nanoai/agentic/cross_tokenizer_kd.py +0 -225
- vendor/nanoai/agentic/dapt_probes.py +0 -194
- vendor/nanoai/agentic/data/__init__.py +0 -1
- vendor/nanoai/agentic/data/codedebug.py +0 -359
- vendor/nanoai/agentic/data/common.py +0 -17
- vendor/nanoai/agentic/data/ebook_data.py +0 -201
- vendor/nanoai/agentic/data/hf_dataset.py +0 -15
- vendor/nanoai/agentic/data/json_dataset.py +0 -73
- vendor/nanoai/agentic/data/mixture.py +0 -42
- vendor/nanoai/agentic/data/pcap_data.py +0 -224
- vendor/nanoai/agentic/data/pretrain_data.py +0 -115
- vendor/nanoai/agentic/data/pretrain_mix_1b.py +0 -466
- vendor/nanoai/agentic/data/routing_recovery.py +0 -357
- vendor/nanoai/agentic/data/sft_data_1b.py +0 -240
- vendor/nanoai/agentic/data/synth_data.py +0 -74
- vendor/nanoai/agentic/data/tool_chain_demo.py +0 -563
- vendor/nanoai/agentic/data/vrcpt_data.py +0 -195
- vendor/nanoai/agentic/dense_verifier.py +0 -247
- vendor/nanoai/agentic/diag/__init__.py +0 -11
- vendor/nanoai/agentic/diag/chain_adapters.py +0 -185
- vendor/nanoai/agentic/diag/graders.py +0 -271
- vendor/nanoai/agentic/diag/graders_v2.py +0 -651
- vendor/nanoai/agentic/diag/graders_v3.py +0 -710
- vendor/nanoai/agentic/diag/schemas.py +0 -341
- vendor/nanoai/agentic/diag/train_scenarios.py +0 -341
- 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
|
| 4 |
-
rebuild the exact
|
| 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
|
| 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
|
| 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
|
| 7 |
-
same as
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 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
|
| 28 |
-
"""
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 39 |
here = os.path.dirname(os.path.abspath(__file__))
|
| 40 |
-
candidates.append(
|
| 41 |
-
env = os.environ.get("
|
| 42 |
if env:
|
| 43 |
candidates.append(env)
|
| 44 |
for c in candidates:
|
| 45 |
-
if c and os.path.isdir(os.path.join(c, "
|
| 46 |
if c not in sys.path:
|
| 47 |
sys.path.insert(0, c)
|
| 48 |
importlib.invalidate_caches()
|
| 49 |
-
|
| 50 |
return
|
| 51 |
raise ImportError(
|
| 52 |
-
"RavenGuard:
|
| 53 |
-
"
|
| 54 |
)
|
| 55 |
|
| 56 |
|
|
@@ -64,9 +67,9 @@ class RavenGuardForCausalLM(PreTrainedModel):
|
|
| 64 |
|
| 65 |
def __init__(self, config):
|
| 66 |
super().__init__(config)
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 85 |
"""
|
| 86 |
if getattr(tokenizer, "chat_kind", None) == "qwen":
|
| 87 |
-
from
|
| 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
|
| 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
|
| 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 |
-
|
| 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
|
| 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
|
| 128 |
-
#
|
| 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
|
| 12 |
-
from
|
| 13 |
-
from
|
| 14 |
-
from
|
| 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
|
| 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
|
| 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
|
| 16 |
_DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
| 17 |
def _detect_compute_dtype():
|
| 18 |
-
env = os.environ.get("
|
| 19 |
if env is not None:
|
| 20 |
-
return _DTYPE_MAP[env], f"set via
|
| 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
|
| 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
|
| 72 |
# Back-compat: the project was formerly "nanochat". NANOCHAT_BASE_DIR is mirrored
|
| 73 |
-
# onto
|
| 74 |
# but a legacy ~/.cache/nanochat does, we use it so checkpoints trained under the
|
| 75 |
-
# old name are not orphaned. Set
|
| 76 |
-
env = os.environ.get("
|
| 77 |
if env:
|
| 78 |
base_dir = env
|
| 79 |
else:
|
| 80 |
cache_dir = os.path.join(os.path.expanduser("~"), ".cache")
|
| 81 |
-
|
| 82 |
legacy_dir = os.path.join(cache_dir, "nanochat")
|
| 83 |
-
if not os.path.isdir(
|
| 84 |
-
logger.warning("
|
| 85 |
base_dir = legacy_dir
|
| 86 |
else:
|
| 87 |
-
base_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
|
| 196 |
# ablations (varies weight init only; data order is fixed by explicit rng objects).
|
| 197 |
-
_seed = int(os.environ.get("
|
| 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/
|
| 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
|
| 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
|
| 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("
|
| 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
|
| 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
|
| 25 |
-
from
|
| 26 |
# GPTConfig is the single shared config (torch + MLX); re-exported here so
|
| 27 |
-
# `from
|
| 28 |
-
from
|
| 29 |
|
| 30 |
# Our custom Flash Attention module that automatically uses FA3 on Hopper+ and SDPA fallback elsewhere
|
| 31 |
-
from
|
| 32 |
# MSA (MiniMax-M3-style block-sparse attention) — self-contained subpackage
|
| 33 |
-
from
|
| 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
|
| 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
|
| 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
|
| 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
|
| 2 |
|
| 3 |
-
The single ``GPTConfig`` used by BOTH backends — the torch model (``
|
| 4 |
-
and the MLX model (``
|
| 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 ``
|
| 10 |
-
existing ``from
|
| 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 (``
|
| 28 |
-
(``
|
| 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
|
| 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 |
-
#
|
| 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("
|
| 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
|
| 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
|
| 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
|
| 18 |
msa_block_sparse_attn_func,
|
| 19 |
msa_gather_decode,
|
| 20 |
_msa_block_selection,
|
| 21 |
update_msa_pooled_cache,
|
| 22 |
)
|
| 23 |
-
from
|
| 24 |
-
from
|
| 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.
|
| 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
|
| 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 |
-
|
| 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
|
| 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
|
| 2 |
|
| 3 |
-
Drop-in replacement for
|
| 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
|
| 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
|
| 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).
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
#
|
| 389 |
|
| 390 |
def get_tokenizer():
|
| 391 |
-
from
|
| 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;
|
| 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
|
| 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
|
| 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
|
| 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 (````) 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  or 
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|