vimalk78 commited on
Commit
222a479
·
verified ·
1 Parent(s): 3fae707

Initial app: Gradio missing-field suggester (v6.1 model)

Browse files
README.md CHANGED
@@ -12,4 +12,36 @@ license: mit
12
  short_description: Tree-aware transformer for structured (YAML) data
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  short_description: Tree-aware transformer for structured (YAML) data
13
  ---
14
 
15
+ # YAML-BERT Kubernetes missing-field suggester
16
+
17
+ A small (7.8M-param) BERT-style encoder trained on 276K Kubernetes YAML
18
+ manifests with **tree-aware positional encoding** and **hybrid bigram/
19
+ trigram prediction targets**. Paste a YAML manifest below; the model
20
+ identifies fields it expects to see but that are absent, ranked by
21
+ confidence.
22
+
23
+ This Space runs the **v6.1 checkpoint** — same architecture as v5, with
24
+ a 5-line bug-fix to selective masking. See the project repo for full
25
+ details.
26
+
27
+ ## What the model does
28
+
29
+ - Predicts standard Kubernetes structural fields (`spec`, `replicas`,
30
+ `containers.image`, etc.)
31
+ - Distinguishes kind-specific fields (`Deployment.replicas`,
32
+ `Service.ports`)
33
+ - Calibrated confidence: strong on common patterns, weaker on ambiguous
34
+ positions
35
+
36
+ ## Known limitations
37
+
38
+ - Status-side fields not yet predicted (addressed in v6.2)
39
+ - Novel CRD instances and very rare annotation keys may collapse to
40
+ `[UNK]`
41
+ - Trained on `substratusai/the-stack-yaml-k8s`
42
+
43
+ ## Links
44
+
45
+ - Code & docs: <https://github.com/vimalk78/yaml-bert>
46
+ - Evaluation results: <https://github.com/vimalk78/yaml-bert/blob/main/docs/evaluation-results.md>
47
+ - v6 plan: <https://github.com/vimalk78/yaml-bert/blob/main/docs/v6-plan.md>
app.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YAML-BERT missing-field suggester — Gradio demo.
2
+
3
+ Paste a Kubernetes YAML manifest; the model identifies fields it expects
4
+ to see but that are absent. Runs the v6.1 checkpoint (Lever 1 — selective
5
+ masking applied during training).
6
+
7
+ Run locally:
8
+ pip install gradio
9
+ PYTHONPATH=. python app.py
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+
16
+ import gradio as gr
17
+ import torch
18
+
19
+ from yaml_bert.config import YamlBertConfig
20
+ from yaml_bert.embedding import YamlBertEmbedding
21
+ from yaml_bert.model import YamlBertModel
22
+ from yaml_bert.suggest import suggest_missing_fields
23
+ from yaml_bert.vocab import Vocabulary
24
+
25
+
26
+ # ----- Model loading (once at startup) -----
27
+
28
+ DEFAULT_CHECKPOINT = "output_v6.1_lever1_only_seed42/checkpoints/yaml_bert_v4_epoch_30.pt"
29
+ DEFAULT_VOCAB = "output_v6.1_lever1_only_seed42/vocab.json"
30
+
31
+
32
+ def load_model(checkpoint_path: str, vocab_path: str) -> tuple[YamlBertModel, Vocabulary]:
33
+ vocab = Vocabulary.load(vocab_path)
34
+ config = YamlBertConfig()
35
+ emb = YamlBertEmbedding(
36
+ config=config,
37
+ key_vocab_size=vocab.key_vocab_size,
38
+ value_vocab_size=vocab.value_vocab_size,
39
+ )
40
+ model = YamlBertModel(
41
+ config=config,
42
+ embedding=emb,
43
+ simple_vocab_size=vocab.simple_target_vocab_size,
44
+ kind_vocab_size=vocab.kind_target_vocab_size,
45
+ )
46
+ cp = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
47
+ model.load_state_dict(cp["model_state_dict"])
48
+ model.eval()
49
+ return model, vocab
50
+
51
+
52
+ checkpoint_path = os.environ.get("YAML_BERT_CHECKPOINT", DEFAULT_CHECKPOINT)
53
+ vocab_path = os.environ.get("YAML_BERT_VOCAB", DEFAULT_VOCAB)
54
+
55
+ print(f"Loading model from {checkpoint_path} (vocab: {vocab_path})", file=sys.stderr)
56
+ MODEL, VOCAB = load_model(checkpoint_path, vocab_path)
57
+ n_params = sum(p.numel() for p in MODEL.parameters())
58
+ print(f"Loaded {n_params:,} parameters", file=sys.stderr)
59
+
60
+
61
+ # ----- Inference -----
62
+
63
+ def suggest(yaml_text: str, threshold: float, top_k: int) -> str:
64
+ yaml_text = (yaml_text or "").strip()
65
+ if not yaml_text:
66
+ return "_Paste a YAML manifest above to see missing-field suggestions._"
67
+
68
+ try:
69
+ suggestions = suggest_missing_fields(
70
+ MODEL, VOCAB, yaml_text,
71
+ threshold=threshold, top_k=top_k,
72
+ )
73
+ except Exception as e:
74
+ return f"**Error parsing YAML:**\n```\n{e}\n```"
75
+
76
+ if not suggestions:
77
+ return ("_No suggestions above threshold. Model thinks this YAML is "
78
+ "either complete, or it doesn't have strong opinions at this confidence level._")
79
+
80
+ # Group by parent_path for readability
81
+ by_parent: dict[str, list[dict]] = {}
82
+ for s in suggestions:
83
+ by_parent.setdefault(s.get("parent_path") or "(root)", []).append(s)
84
+
85
+ blocks: list[str] = []
86
+ for parent in sorted(by_parent.keys(), key=lambda p: -max(s["confidence"] for s in by_parent[p])):
87
+ rows = ["| Missing key | Confidence | Strength |", "|---|---:|---|"]
88
+ for s in sorted(by_parent[parent], key=lambda x: -x["confidence"]):
89
+ conf = s["confidence"]
90
+ strength = "**STRONG**" if conf >= 0.7 else ("MODERATE" if conf >= 0.5 else "weak")
91
+ rows.append(f"| `{s['missing_key']}` | {conf:.1%} | {strength} |")
92
+ blocks.append(f"### `{parent}`\n" + "\n".join(rows))
93
+
94
+ return "\n\n".join(blocks)
95
+
96
+
97
+ # ----- UI -----
98
+
99
+ EXAMPLE_NGINX = """apiVersion: apps/v1
100
+ kind: Deployment
101
+ metadata:
102
+ name: nginx-deployment
103
+ spec:
104
+ selector:
105
+ matchLabels:
106
+ app: nginx
107
+ replicas: 2
108
+ template:
109
+ metadata:
110
+ labels:
111
+ app: nginx
112
+ spec:
113
+ containers:
114
+ - name: nginx
115
+ image: nginx:1.8
116
+ resources:
117
+ limits:
118
+ memory: "128Mi"
119
+ cpu: "250m"
120
+ ports:
121
+ - containerPort: 80
122
+ """
123
+
124
+ EXAMPLE_INCOMPLETE_SERVICE = """apiVersion: v1
125
+ kind: Service
126
+ metadata:
127
+ name: my-svc
128
+ spec:
129
+ selector:
130
+ app: web
131
+ """
132
+
133
+ EXAMPLE_CONFIGMAP = """apiVersion: v1
134
+ kind: ConfigMap
135
+ metadata:
136
+ name: app-config
137
+ data:
138
+ key1: value1
139
+ """
140
+
141
+ with gr.Blocks(title="YAML-BERT — missing-field suggester") as demo:
142
+ gr.Markdown(
143
+ f"""
144
+ # YAML-BERT — Kubernetes missing-field suggester
145
+
146
+ A small (7.8M-param) BERT-style encoder trained on 276K Kubernetes YAML manifests with
147
+ **tree-aware positional encoding** and **hybrid bigram/trigram prediction targets**.
148
+ This page runs the **v6.1 checkpoint** — same architecture as v5, with a 5-line bug
149
+ fix to selective masking ([details](https://github.com/vimalk78/yaml-bert/blob/main/docs/evaluation-results.md#7-v61--lever-1-selective-masking)).
150
+
151
+ **How it works:** the model walks each parent level in your YAML, inserts a fake
152
+ `[MASK]` node, and reports the keys it expects to see there but that aren't present.
153
+ Confidence reflects how strongly the model "expects" each missing field.
154
+
155
+ Code: [github.com/vimalk78/yaml-bert](https://github.com/vimalk78/yaml-bert) ·
156
+ {n_params:,} params
157
+ """
158
+ )
159
+
160
+ with gr.Row():
161
+ with gr.Column(scale=1):
162
+ yaml_input = gr.Code(
163
+ language="yaml",
164
+ lines=22,
165
+ label="YAML input",
166
+ value=EXAMPLE_NGINX,
167
+ )
168
+ with gr.Row():
169
+ threshold = gr.Slider(
170
+ minimum=0.1, maximum=0.95, value=0.3, step=0.05,
171
+ label="Confidence threshold",
172
+ )
173
+ top_k = gr.Slider(
174
+ minimum=3, maximum=20, value=10, step=1,
175
+ label="Top-K predictions per position",
176
+ )
177
+ submit = gr.Button("Suggest missing fields", variant="primary")
178
+
179
+ with gr.Column(scale=1):
180
+ output = gr.Markdown(label="Suggestions", value="")
181
+
182
+ submit.click(fn=suggest, inputs=[yaml_input, threshold, top_k], outputs=output)
183
+ yaml_input.change(fn=suggest, inputs=[yaml_input, threshold, top_k], outputs=output)
184
+
185
+ gr.Examples(
186
+ examples=[
187
+ [EXAMPLE_NGINX, 0.3, 10],
188
+ [EXAMPLE_INCOMPLETE_SERVICE, 0.3, 10],
189
+ [EXAMPLE_CONFIGMAP, 0.3, 10],
190
+ ],
191
+ inputs=[yaml_input, threshold, top_k],
192
+ label="Example YAMLs",
193
+ )
194
+
195
+ gr.Markdown(
196
+ """
197
+ ---
198
+
199
+ ### What this model does well
200
+ - Predicts standard Kubernetes structural fields (`spec`, `replicas`, `containers.image`, etc.)
201
+ - Distinguishes kind-specific fields (`Deployment.replicas`, `Service.ports`)
202
+ - Calibrated confidence: strong on common patterns, weaker on ambiguous positions
203
+
204
+ ### Known limitations
205
+ - **Status-side fields not yet predicted** (still being addressed in v6.2)
206
+ - Novel CRD instances and very rare annotation keys may collapse to `[UNK]`
207
+ - Trained on `substratusai/the-stack-yaml-k8s` (276K manifests from HF)
208
+ """
209
+ )
210
+
211
+
212
+ if __name__ == "__main__":
213
+ demo.launch()
output_v6.1_lever1_only_seed42/checkpoints/yaml_bert_v4_epoch_30.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dc057c85c31db30efafca5d89486f629ce0831efc9b9560dfd0d7dfca483b198
3
+ size 93942115
output_v6.1_lever1_only_seed42/vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ torch>=2.0
2
+ pyyaml>=6.0
yaml_bert/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YAML-BERT: Attention on Kubernetes Structured Data."""
2
+
3
+ from yaml_bert.config import YamlBertConfig
4
+ from yaml_bert.types import NodeType, YamlNode
5
+ from yaml_bert.linearizer import YamlLinearizer
6
+ from yaml_bert.vocab import Vocabulary, VocabBuilder
7
+ from yaml_bert.annotator import DomainAnnotator
8
+ from yaml_bert.embedding import YamlBertEmbedding
9
+ from yaml_bert.model import YamlBertModel
10
+ from yaml_bert.dataset import YamlDataset, collate_fn
11
+ from yaml_bert.trainer import YamlBertTrainer
12
+
13
+ __all__ = [
14
+ "YamlBertConfig",
15
+ "NodeType",
16
+ "YamlNode",
17
+ "YamlLinearizer",
18
+ "Vocabulary",
19
+ "VocabBuilder",
20
+ "DomainAnnotator",
21
+ "YamlBertEmbedding",
22
+ "YamlBertModel",
23
+ "YamlDataset",
24
+ "collate_fn",
25
+ "YamlBertTrainer",
26
+ ]
yaml_bert/annotator.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from yaml_bert.types import NodeType, YamlNode
4
+
5
+
6
+ class DomainAnnotator:
7
+ ORDERED_LISTS = {"initContainers"}
8
+
9
+ def annotate(self, nodes: list[YamlNode]) -> list[YamlNode]:
10
+ list_parent_ids = self._find_list_parent_ids(nodes)
11
+ for node in nodes:
12
+ if id(node) in list_parent_ids:
13
+ node.annotations["list_ordered"] = (
14
+ node.token in self.ORDERED_LISTS
15
+ )
16
+ return nodes
17
+
18
+ def _find_list_parent_ids(self, nodes: list[YamlNode]) -> set[int]:
19
+ list_parent_ids: set[int] = set()
20
+ parent_paths_with_list_items: set[str] = set()
21
+
22
+ for node in nodes:
23
+ if node.node_type in (NodeType.LIST_KEY, NodeType.LIST_VALUE):
24
+ parts = node.parent_path.split(".")
25
+ for i, part in enumerate(parts):
26
+ if part.isdigit():
27
+ list_parent_path = ".".join(parts[:i])
28
+ parent_paths_with_list_items.add(list_parent_path)
29
+ break
30
+
31
+ for node in nodes:
32
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
33
+ node_full_path = (
34
+ f"{node.parent_path}.{node.token}" if node.parent_path else node.token
35
+ )
36
+ if node_full_path in parent_paths_with_list_items:
37
+ list_parent_ids.add(id(node))
38
+
39
+ return list_parent_ids
yaml_bert/attention_pooling.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+
6
+
7
+ class AttentionPooling(nn.Module):
8
+ def __init__(self, d_model: int) -> None:
9
+ super().__init__()
10
+ self.d_model = d_model
11
+ self.W_doc = nn.Linear(in_features=d_model, out_features=1) # (d_model,1)
12
+
13
+ def forward(
14
+ self,
15
+ h: torch.Tensor, # Shape(Batch, Seq_Len, d_model)
16
+ ):
17
+ scores: torch.Tensor = self.W_doc(h) # (Batch, Seq_Len, 1)
18
+ scores = scores / math.sqrt(self.d_model)
19
+ scores = scores.view(scores.shape[0], 1, scores.shape[1]) # (Batch, 1, Seq_Len)
20
+ weights: torch.Tensor = scores.softmax(dim=-1) # (Batch,1, Seq_Len)
21
+ return (
22
+ (weights @ h).squeeze(1),
23
+ weights,
24
+ ) # returning weights for testing/visualization (Batch,d_model), (Batch,1,Seq_Len)
yaml_bert/cache.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cache linearized YAML documents to disk.
2
+
3
+ Linearizes all documents once (with multiprocessing), saves to pickle.
4
+ Both VocabBuilder and YamlDataset load from cache — no re-parsing.
5
+
6
+ Usage:
7
+ from yaml_bert.cache import build_or_load_cache
8
+
9
+ docs = build_or_load_cache(
10
+ dataset_name="substratusai/the-stack-yaml-k8s",
11
+ cache_path="output_v3/doc_cache.pkl",
12
+ max_docs=276520,
13
+ )
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import pickle
19
+ import time
20
+ from multiprocessing import Pool, cpu_count
21
+ from typing import Any
22
+
23
+ from yaml_bert.annotator import DomainAnnotator
24
+ from yaml_bert.linearizer import YamlLinearizer
25
+ from yaml_bert.types import YamlNode
26
+
27
+
28
+ def _linearize_one(yaml_content: str) -> list[YamlNode] | None:
29
+ """Linearize a single YAML string. Used by multiprocessing pool."""
30
+ try:
31
+ linearizer = YamlLinearizer()
32
+ annotator = DomainAnnotator()
33
+ nodes: list[YamlNode] = linearizer.linearize(yaml_content)
34
+ if nodes:
35
+ annotator.annotate(nodes)
36
+ return nodes
37
+ except Exception:
38
+ pass
39
+ return None
40
+
41
+
42
+ def build_or_load_cache(
43
+ dataset_name: str,
44
+ cache_path: str,
45
+ max_docs: int | None = None,
46
+ num_workers: int | None = None,
47
+ ) -> list[list[YamlNode]]:
48
+ """Build or load cached linearized documents.
49
+
50
+ Args:
51
+ dataset_name: HuggingFace dataset ID
52
+ cache_path: Path to save/load the cache pickle
53
+ max_docs: Max documents to process (None = all)
54
+ num_workers: Number of parallel workers (None = cpu_count)
55
+
56
+ Returns:
57
+ List of linearized documents (each is a list of YamlNode)
58
+ """
59
+ if os.path.exists(cache_path):
60
+ print(f"Loading cached documents from {cache_path}")
61
+ start: float = time.time()
62
+ with open(cache_path, "rb") as f:
63
+ documents: list[list[YamlNode]] = pickle.load(f)
64
+ print(f"Loaded {len(documents):,} documents in {time.time() - start:.1f}s")
65
+ return documents
66
+
67
+ from datasets import load_dataset
68
+
69
+ print(f"Building document cache from {dataset_name}")
70
+ ds = load_dataset(dataset_name, split="train")
71
+
72
+ total: int = len(ds) if max_docs is None else min(max_docs, len(ds))
73
+ print(f"Linearizing {total:,} documents with {num_workers or cpu_count()} workers...")
74
+
75
+ # Extract YAML content strings
76
+ yaml_contents: list[str] = [ds[i]["content"] for i in range(total)]
77
+
78
+ # Parallel linearization with progress bar
79
+ from tqdm import tqdm
80
+
81
+ start = time.time()
82
+ workers: int = num_workers or cpu_count()
83
+ with Pool(workers) as pool:
84
+ results: list[list[YamlNode] | None] = list(tqdm(
85
+ pool.imap(_linearize_one, yaml_contents, chunksize=500),
86
+ total=len(yaml_contents),
87
+ desc="Linearizing",
88
+ ))
89
+
90
+ documents = [doc for doc in results if doc is not None]
91
+ skipped: int = sum(1 for doc in results if doc is None)
92
+ elapsed: float = time.time() - start
93
+
94
+ print(f"Linearized {len(documents):,} documents in {elapsed:.1f}s ({skipped} skipped)")
95
+
96
+ # Save cache
97
+ os.makedirs(os.path.dirname(cache_path) or ".", exist_ok=True)
98
+ with open(cache_path, "wb") as f:
99
+ pickle.dump(documents, f)
100
+
101
+ cache_size: float = os.path.getsize(cache_path) / 1024 / 1024
102
+ print(f"Cache saved: {cache_path} ({cache_size:.1f} MB)")
103
+
104
+ return documents
yaml_bert/config.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+
7
+ class TreePosVariant(str, Enum):
8
+ """Tree positional encoding ablation variants."""
9
+
10
+ FULL = "full" # depth + sibling + node_type (default)
11
+ NO_DEPTH = "no_depth" # sibling + node_type
12
+ NO_SIBLING = "no_sibling" # depth + node_type
13
+ SEQUENTIAL = "sequential" # learned pos[seq_idx] + node_type
14
+
15
+
16
+ @dataclass
17
+ class YamlBertConfig:
18
+ """Central configuration for YAML-BERT hyperparameters."""
19
+
20
+ # Model architecture
21
+ d_model: int = 256
22
+ num_layers: int = 6
23
+ num_heads: int = 8
24
+ d_ff: int = 0 # 0 means "auto: 4 * d_model"
25
+ max_depth: int = 16
26
+ max_sibling: int = 32
27
+ tree_pos_variant: TreePosVariant = TreePosVariant.FULL
28
+
29
+ # Training
30
+ mask_prob: float = 0.15
31
+ lr: float = 1e-4
32
+ batch_size: int = 32
33
+ num_epochs: int = 30
34
+ max_seq_len: int = 512
35
+ skip_unk_targets: bool = True # v6: don't supervise on positions whose target is [UNK]
36
+
37
+ # Auxiliary loss weights
38
+ aux_kind_weight: float = 0.1 # α: kind classification loss weight
39
+ aux_parent_weight: float = 0.1 # β: parent_key classification loss weight
40
+
41
+ def __post_init__(self) -> None:
42
+ if self.d_ff == 0:
43
+ self.d_ff = 4 * self.d_model
yaml_bert/dataset.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import os
5
+ import random
6
+
7
+ import torch
8
+ from torch.utils.data import Dataset
9
+
10
+ from yaml_bert.annotator import DomainAnnotator
11
+ from yaml_bert.config import YamlBertConfig
12
+ from yaml_bert.linearizer import YamlLinearizer
13
+ from yaml_bert.types import NodeType, YamlNode
14
+ from yaml_bert.vocab import Vocabulary, compute_target
15
+
16
+
17
+ def _extract_kind(nodes: list[YamlNode]) -> str:
18
+ """Extract the kind value from a document's node list, normalized to canonical casing."""
19
+ from yaml_bert.vocab import normalize_kind
20
+ for i, node in enumerate(nodes):
21
+ if (node.token == "kind"
22
+ and node.depth == 0
23
+ and node.node_type == NodeType.KEY
24
+ and i + 1 < len(nodes)
25
+ and nodes[i + 1].node_type == NodeType.VALUE):
26
+ return normalize_kind(nodes[i + 1].token)
27
+ return ""
28
+
29
+
30
+ # NodeType to integer index mapping
31
+ _NODE_TYPE_INDEX: dict[NodeType, int] = {
32
+ NodeType.KEY: 0,
33
+ NodeType.VALUE: 1,
34
+ NodeType.LIST_KEY: 2,
35
+ NodeType.LIST_VALUE: 3,
36
+ }
37
+
38
+ # Node types eligible for masking
39
+ _MASKABLE_TYPES: set[NodeType] = {NodeType.KEY, NodeType.LIST_KEY}
40
+
41
+
42
+ class YamlDataset(Dataset):
43
+ """Dataset of linearized, masked YAML documents for YAML-BERT training."""
44
+
45
+ def __init__(
46
+ self,
47
+ yaml_dir: str,
48
+ vocab: Vocabulary,
49
+ linearizer: YamlLinearizer,
50
+ annotator: DomainAnnotator,
51
+ config: YamlBertConfig | None = None,
52
+ ) -> None:
53
+ config = config or YamlBertConfig()
54
+ self.vocab: Vocabulary = vocab
55
+ self.linearizer: YamlLinearizer = linearizer
56
+ self.annotator: DomainAnnotator = annotator
57
+ self.mask_prob: float = config.mask_prob
58
+ self.max_seq_len: int = config.max_seq_len
59
+
60
+ # Load and linearize all YAML files. Skip any files the linearizer
61
+ # can't handle (e.g., uncommon tags like !!value) rather than failing
62
+ # the whole load.
63
+ yaml_files: list[str] = sorted(
64
+ glob.glob(os.path.join(yaml_dir, "**", "*.yaml"), recursive=True)
65
+ )
66
+ self.documents: list[list[YamlNode]] = []
67
+ self.document_kinds: list[str] = []
68
+ skipped: int = 0
69
+ for path in yaml_files:
70
+ try:
71
+ nodes: list[YamlNode] = linearizer.linearize_file(path)
72
+ except Exception:
73
+ skipped += 1
74
+ continue
75
+ if nodes:
76
+ annotator.annotate(nodes)
77
+ self.documents.append(nodes)
78
+ self.document_kinds.append(_extract_kind(nodes))
79
+ if skipped:
80
+ print(f"Skipped {skipped} unparseable YAML files")
81
+
82
+ @classmethod
83
+ def from_huggingface(
84
+ cls,
85
+ dataset_name: str,
86
+ vocab: Vocabulary,
87
+ linearizer: YamlLinearizer,
88
+ annotator: DomainAnnotator,
89
+ config: YamlBertConfig | None = None,
90
+ max_docs: int | None = None,
91
+ ) -> "YamlDataset":
92
+ """Load dataset from HuggingFace Hub.
93
+
94
+ Args:
95
+ dataset_name: HuggingFace dataset ID, e.g. "substratusai/the-stack-yaml-k8s"
96
+ vocab: Pre-built vocabulary
97
+ linearizer: YamlLinearizer instance
98
+ annotator: DomainAnnotator instance
99
+ config: Optional config (uses defaults if None)
100
+ max_docs: Load at most this many documents (None = all)
101
+ """
102
+ from datasets import load_dataset
103
+
104
+ config = config or YamlBertConfig()
105
+ instance: YamlDataset = cls.__new__(cls)
106
+ instance.vocab = vocab
107
+ instance.linearizer = linearizer
108
+ instance.annotator = annotator
109
+ instance.mask_prob = config.mask_prob
110
+ instance.max_seq_len = config.max_seq_len
111
+ instance.documents = []
112
+ instance.document_kinds = []
113
+
114
+ print(f"Loading dataset: {dataset_name}")
115
+ ds = load_dataset(dataset_name, split="train")
116
+
117
+ total: int = len(ds) if max_docs is None else min(max_docs, len(ds))
118
+ print(f"Linearizing {total:,} / {len(ds):,} documents...")
119
+
120
+ skipped: int = 0
121
+ for i in range(total):
122
+ yaml_content: str = ds[i]["content"]
123
+ try:
124
+ nodes: list[YamlNode] = linearizer.linearize(yaml_content)
125
+ except Exception:
126
+ skipped += 1
127
+ continue
128
+ if nodes:
129
+ annotator.annotate(nodes)
130
+ instance.documents.append(nodes)
131
+ instance.document_kinds.append(_extract_kind(nodes))
132
+
133
+ if (i + 1) % 10000 == 0:
134
+ print(f" {i + 1:,} / {total:,} processed ({skipped} skipped)")
135
+
136
+ print(f"Loaded {len(instance.documents):,} documents ({skipped} skipped)")
137
+ return instance
138
+
139
+ @classmethod
140
+ def from_cached_docs(
141
+ cls,
142
+ documents: list[list[YamlNode]],
143
+ vocab: Vocabulary,
144
+ config: YamlBertConfig | None = None,
145
+ ) -> "YamlDataset":
146
+ """Build dataset from pre-linearized cached documents. No parsing needed."""
147
+ config = config or YamlBertConfig()
148
+ instance: YamlDataset = cls.__new__(cls)
149
+ instance.vocab = vocab
150
+ instance.linearizer = None
151
+ instance.annotator = None
152
+ instance.mask_prob = config.mask_prob
153
+ instance.max_seq_len = config.max_seq_len
154
+ instance.documents = documents
155
+ instance.document_kinds = [_extract_kind(doc) for doc in documents]
156
+ return instance
157
+
158
+ @classmethod
159
+ def from_cached_docs_v4(
160
+ cls,
161
+ documents: list[list[YamlNode]],
162
+ vocab: Vocabulary,
163
+ config: YamlBertConfig | None = None,
164
+ ) -> "YamlDataset":
165
+ """Build v4 dataset from pre-linearized cached documents."""
166
+ config = config or YamlBertConfig()
167
+ instance = cls.__new__(cls)
168
+ instance.vocab = vocab
169
+ instance.linearizer = None
170
+ instance.annotator = None
171
+ instance.mask_prob = config.mask_prob
172
+ instance.max_seq_len = config.max_seq_len
173
+ instance.skip_unk_targets = config.skip_unk_targets
174
+ instance.documents = documents
175
+ instance.document_kinds = [_extract_kind(doc) for doc in documents]
176
+ instance._v4_mode = True # Flag for __getitem__
177
+ return instance
178
+
179
+ def __len__(self) -> int:
180
+ return len(self.documents)
181
+
182
+ def _getitem_v4(self, idx: int) -> dict[str, torch.Tensor]:
183
+ """v4 item encoding: hybrid simple/kind labels, no parent_key_ids or kind_ids."""
184
+ nodes: list[YamlNode] = self.documents[idx]
185
+
186
+ # Truncate if needed
187
+ if len(nodes) > self.max_seq_len:
188
+ nodes = nodes[: self.max_seq_len]
189
+
190
+ seq_len: int = len(nodes)
191
+ kind: str = self.document_kinds[idx]
192
+
193
+ token_ids: list[int] = []
194
+ node_types: list[int] = []
195
+ depths: list[int] = []
196
+ sibling_indices: list[int] = []
197
+
198
+ for node in nodes:
199
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
200
+ token_ids.append(self.vocab.encode_key(node.token))
201
+ else:
202
+ token_ids.append(self.vocab.encode_value(node.token))
203
+
204
+ node_types.append(_NODE_TYPE_INDEX[node.node_type])
205
+ depths.append(min(node.depth, 15))
206
+ sibling_indices.append(min(node.sibling_index, 31))
207
+
208
+ # Apply masking
209
+ simple_labels: list[int] = [-100] * seq_len
210
+ kind_labels: list[int] = [-100] * seq_len
211
+ mask_token_id: int = self.vocab.special_tokens["[MASK]"]
212
+ unk_id: int = self.vocab.special_tokens["[UNK]"]
213
+ skip_unk: bool = getattr(self, "skip_unk_targets", True)
214
+
215
+ for i in range(seq_len):
216
+ if nodes[i].node_type not in _MASKABLE_TYPES:
217
+ continue
218
+ if random.random() >= self.mask_prob:
219
+ continue
220
+
221
+ # Compute hybrid target FIRST so we can skip positions whose
222
+ # target is [UNK] (Lever 1, v6.1 — don't train the model to
223
+ # confidently predict [UNK])
224
+ target, head_type = compute_target(nodes[i], kind)
225
+ if head_type == "simple":
226
+ target_id = self.vocab.encode_simple_target(target)
227
+ else:
228
+ target_id = self.vocab.encode_kind_target(target)
229
+
230
+ if skip_unk and target_id == unk_id:
231
+ continue
232
+
233
+ if head_type == "simple":
234
+ simple_labels[i] = target_id
235
+ kind_labels[i] = -100
236
+ else:
237
+ kind_labels[i] = target_id
238
+ simple_labels[i] = -100
239
+
240
+ # Apply token replacement (80/10/10 rule)
241
+ rand: float = random.random()
242
+ if rand < 0.8:
243
+ token_ids[i] = mask_token_id
244
+ elif rand < 0.9:
245
+ random_key_id: int = random.randint(
246
+ len(self.vocab.special_tokens),
247
+ len(self.vocab.key_vocab) + len(self.vocab.special_tokens) - 1,
248
+ )
249
+ token_ids[i] = random_key_id
250
+ # else 10%: keep unchanged
251
+
252
+ return {
253
+ "token_ids": torch.tensor(token_ids, dtype=torch.long),
254
+ "node_types": torch.tensor(node_types, dtype=torch.long),
255
+ "depths": torch.tensor(depths, dtype=torch.long),
256
+ "sibling_indices": torch.tensor(sibling_indices, dtype=torch.long),
257
+ "simple_labels": torch.tensor(simple_labels, dtype=torch.long),
258
+ "kind_labels": torch.tensor(kind_labels, dtype=torch.long),
259
+ }
260
+
261
+ def __getitem__(self, idx: int) -> dict[str, torch.Tensor]:
262
+ if getattr(self, "_v4_mode", False):
263
+ return self._getitem_v4(idx)
264
+
265
+ nodes: list[YamlNode] = self.documents[idx]
266
+
267
+ # Truncate if needed
268
+ if len(nodes) > self.max_seq_len:
269
+ nodes = nodes[: self.max_seq_len]
270
+
271
+ seq_len: int = len(nodes)
272
+
273
+ # Encode nodes to integer arrays
274
+ token_ids: list[int] = []
275
+ node_types: list[int] = []
276
+ depths: list[int] = []
277
+ sibling_indices: list[int] = []
278
+ parent_key_ids: list[int] = []
279
+
280
+ for node in nodes:
281
+ # Token ID — route to correct vocab
282
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
283
+ token_ids.append(self.vocab.encode_key(node.token))
284
+ else:
285
+ token_ids.append(self.vocab.encode_value(node.token))
286
+
287
+ node_types.append(_NODE_TYPE_INDEX[node.node_type])
288
+ depths.append(min(node.depth, 15)) # clamp to max_depth - 1
289
+ sibling_indices.append(min(node.sibling_index, 31)) # clamp to max_sibling - 1
290
+
291
+ parent_key: str = Vocabulary.extract_parent_key(node.parent_path)
292
+ parent_key_ids.append(self.vocab.encode_key(parent_key))
293
+
294
+ # Apply masking (only to KEY and LIST_KEY nodes)
295
+ labels: list[int] = [-100] * seq_len
296
+ mask_token_id: int = self.vocab.special_tokens["[MASK]"]
297
+
298
+ for i in range(seq_len):
299
+ if nodes[i].node_type not in _MASKABLE_TYPES:
300
+ continue
301
+ if random.random() >= self.mask_prob:
302
+ continue
303
+
304
+ # This position is selected for masking
305
+ labels[i] = token_ids[i] # save original token ID as label
306
+
307
+ rand: float = random.random()
308
+ if rand < 0.8:
309
+ # 80%: replace with [MASK]
310
+ token_ids[i] = mask_token_id
311
+ elif rand < 0.9:
312
+ # 10%: replace with random key
313
+ random_key_id: int = random.randint(
314
+ len(self.vocab.special_tokens),
315
+ len(self.vocab.key_vocab) + len(self.vocab.special_tokens) - 1,
316
+ )
317
+ token_ids[i] = random_key_id
318
+ # else 10%: keep unchanged
319
+
320
+ kind: str = self.document_kinds[idx]
321
+ kind_id: int = self.vocab.encode_kind(kind)
322
+ kind_ids: list[int] = [kind_id] * seq_len
323
+
324
+ return {
325
+ "token_ids": torch.tensor(token_ids, dtype=torch.long),
326
+ "node_types": torch.tensor(node_types, dtype=torch.long),
327
+ "depths": torch.tensor(depths, dtype=torch.long),
328
+ "sibling_indices": torch.tensor(sibling_indices, dtype=torch.long),
329
+ "parent_key_ids": torch.tensor(parent_key_ids, dtype=torch.long),
330
+ "labels": torch.tensor(labels, dtype=torch.long),
331
+ "kind_ids": torch.tensor(kind_ids, dtype=torch.long),
332
+ }
333
+
334
+
335
+ def collate_fn(batch: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
336
+ """Pad a batch of variable-length sequences and create padding mask."""
337
+ max_len: int = max(item["token_ids"].size(0) for item in batch)
338
+
339
+ padded: dict[str, list[torch.Tensor]] = {key: [] for key in batch[0].keys()}
340
+ padding_masks: list[torch.Tensor] = []
341
+
342
+ for item in batch:
343
+ seq_len: int = item["token_ids"].size(0)
344
+ pad_len: int = max_len - seq_len
345
+
346
+ for key in item:
347
+ if pad_len > 0:
348
+ pad_value: int = -100 if (key == "labels" or key.endswith("_labels")) else 0
349
+ padding: torch.Tensor = torch.full(
350
+ (pad_len,), pad_value, dtype=torch.long
351
+ )
352
+ padded[key].append(torch.cat([item[key], padding]))
353
+ else:
354
+ padded[key].append(item[key])
355
+
356
+ mask: torch.Tensor = torch.cat([
357
+ torch.zeros(seq_len, dtype=torch.bool),
358
+ torch.ones(pad_len, dtype=torch.bool),
359
+ ]) if pad_len > 0 else torch.zeros(seq_len, dtype=torch.bool)
360
+ padding_masks.append(mask)
361
+
362
+ result: dict[str, torch.Tensor] = {
363
+ key: torch.stack(tensors) for key, tensors in padded.items()
364
+ }
365
+ result["padding_mask"] = torch.stack(padding_masks)
366
+
367
+ return result
yaml_bert/embedding.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+
6
+ from yaml_bert.config import TreePosVariant, YamlBertConfig
7
+
8
+
9
+ class YamlBertEmbedding(nn.Module):
10
+ """Embedding layer with tree positional encoding for YAML-BERT.
11
+
12
+ Produces input vectors by summing:
13
+ - Token embedding (key_embedding or value_embedding, routed by node_type)
14
+ - Tree positional encoding (composition depends on config.tree_pos_variant)
15
+
16
+ Kind and parent awareness come from the hybrid prediction targets, not input encoding.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ config: YamlBertConfig,
22
+ key_vocab_size: int,
23
+ value_vocab_size: int,
24
+ ) -> None:
25
+ super().__init__()
26
+ d: int = config.d_model
27
+ variant: TreePosVariant = config.tree_pos_variant
28
+ self.variant: TreePosVariant = variant
29
+
30
+ self.key_embedding: nn.Embedding = nn.Embedding(key_vocab_size, d)
31
+ self.value_embedding: nn.Embedding = nn.Embedding(value_vocab_size, d)
32
+ self.node_type_embedding: nn.Embedding = nn.Embedding(4, d)
33
+
34
+ use_depth: bool = variant in (TreePosVariant.FULL, TreePosVariant.NO_SIBLING)
35
+ use_sibling: bool = variant in (TreePosVariant.FULL, TreePosVariant.NO_DEPTH)
36
+ use_seq_pos: bool = variant == TreePosVariant.SEQUENTIAL
37
+
38
+ self.depth_embedding: nn.Embedding | None = (
39
+ nn.Embedding(config.max_depth, d) if use_depth else None
40
+ )
41
+ self.sibling_embedding: nn.Embedding | None = (
42
+ nn.Embedding(config.max_sibling, d) if use_sibling else None
43
+ )
44
+ self.pos_embedding: nn.Embedding | None = (
45
+ nn.Embedding(config.max_seq_len, d) if use_seq_pos else None
46
+ )
47
+
48
+ self.layer_norm: nn.LayerNorm = nn.LayerNorm(d)
49
+
50
+ def forward(
51
+ self,
52
+ token_ids: torch.Tensor,
53
+ node_types: torch.Tensor,
54
+ depths: torch.Tensor,
55
+ sibling_indices: torch.Tensor,
56
+ ) -> torch.Tensor:
57
+ is_key: torch.Tensor = (node_types == 0) | (node_types == 2)
58
+ key_vocab_size: int = self.key_embedding.num_embeddings
59
+ val_vocab_size: int = self.value_embedding.num_embeddings
60
+ key_emb: torch.Tensor = self.key_embedding(token_ids.clamp(0, key_vocab_size - 1))
61
+ val_emb: torch.Tensor = self.value_embedding(token_ids.clamp(0, val_vocab_size - 1))
62
+ token_emb: torch.Tensor = torch.where(is_key.unsqueeze(-1), key_emb, val_emb)
63
+
64
+ tree_pos: torch.Tensor = self.node_type_embedding(node_types)
65
+ if self.depth_embedding is not None:
66
+ tree_pos = tree_pos + self.depth_embedding(depths)
67
+ if self.sibling_embedding is not None:
68
+ tree_pos = tree_pos + self.sibling_embedding(sibling_indices)
69
+ if self.pos_embedding is not None:
70
+ seq_len: int = token_ids.size(1)
71
+ max_pos: int = self.pos_embedding.num_embeddings
72
+ positions: torch.Tensor = (
73
+ torch.arange(seq_len, device=token_ids.device)
74
+ .clamp(max=max_pos - 1)
75
+ .unsqueeze(0)
76
+ .expand(token_ids.size(0), seq_len)
77
+ )
78
+ tree_pos = tree_pos + self.pos_embedding(positions)
79
+
80
+ return self.layer_norm(token_emb + tree_pos)
yaml_bert/evaluate.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from yaml_bert.dataset import YamlDataset
9
+ from yaml_bert.model import YamlBertModel
10
+ from yaml_bert.vocab import Vocabulary
11
+
12
+
13
+ class YamlBertEvaluator:
14
+ """Post-training evaluation for YAML-BERT."""
15
+
16
+ def __init__(
17
+ self,
18
+ model: YamlBertModel,
19
+ dataset: YamlDataset,
20
+ vocab: Vocabulary,
21
+ ) -> None:
22
+ self.model: YamlBertModel = model
23
+ self.dataset: YamlDataset = dataset
24
+ self.vocab: Vocabulary = vocab
25
+ self.device: torch.device = next(model.parameters()).device
26
+ self._id_to_simple: dict[int, str] = {
27
+ v: k for k, v in vocab.simple_target_vocab.items()
28
+ }
29
+ self._id_to_kind: dict[int, str] = {
30
+ v: k for k, v in vocab.kind_target_vocab.items()
31
+ }
32
+
33
+ def _decode_simple(self, id: int) -> str:
34
+ if id in self.vocab._id_to_special:
35
+ return self.vocab._id_to_special[id]
36
+ return self._id_to_simple.get(id, "[UNK]")
37
+
38
+ def _decode_kind(self, id: int) -> str:
39
+ if id in self.vocab._id_to_special:
40
+ return self.vocab._id_to_special[id]
41
+ return self._id_to_kind.get(id, "[UNK]")
42
+
43
+ @torch.no_grad()
44
+ def evaluate_prediction_accuracy(self) -> dict[str, float]:
45
+ """Compute top-1 and top-5 masked key prediction accuracy over the dataset.
46
+
47
+ Evaluates both simple and kind-specific heads independently.
48
+ """
49
+ self.model.eval()
50
+
51
+ simple_total: int = 0
52
+ simple_top1: int = 0
53
+ simple_top5: int = 0
54
+ kind_total: int = 0
55
+ kind_top1: int = 0
56
+ kind_top5: int = 0
57
+
58
+ for idx in range(len(self.dataset)):
59
+ item: dict[str, torch.Tensor] = self.dataset[idx]
60
+ simple_labels: torch.Tensor = item["simple_labels"]
61
+ kind_labels: torch.Tensor = item["kind_labels"]
62
+
63
+ simple_masked: torch.Tensor = simple_labels != -100
64
+ kind_masked: torch.Tensor = kind_labels != -100
65
+ if not simple_masked.any() and not kind_masked.any():
66
+ continue
67
+
68
+ simple_logits, kind_logits = self.model(
69
+ token_ids=item["token_ids"].unsqueeze(0).to(self.device),
70
+ node_types=item["node_types"].unsqueeze(0).to(self.device),
71
+ depths=item["depths"].unsqueeze(0).to(self.device),
72
+ sibling_indices=item["sibling_indices"].unsqueeze(0).to(self.device),
73
+ )
74
+
75
+ s_logits: torch.Tensor = simple_logits[0]
76
+ for pos in simple_masked.nonzero(as_tuple=True)[0]:
77
+ true_id: int = simple_labels[pos].item()
78
+ pos_logits: torch.Tensor = s_logits[pos]
79
+ top5_ids: torch.Tensor = pos_logits.topk(5).indices
80
+
81
+ if top5_ids[0].item() == true_id:
82
+ simple_top1 += 1
83
+ if true_id in top5_ids.tolist():
84
+ simple_top5 += 1
85
+ simple_total += 1
86
+
87
+ k_logits: torch.Tensor = kind_logits[0]
88
+ for pos in kind_masked.nonzero(as_tuple=True)[0]:
89
+ true_id = kind_labels[pos].item()
90
+ pos_logits = k_logits[pos]
91
+ top5_ids = pos_logits.topk(5).indices
92
+
93
+ if top5_ids[0].item() == true_id:
94
+ kind_top1 += 1
95
+ if true_id in top5_ids.tolist():
96
+ kind_top5 += 1
97
+ kind_total += 1
98
+
99
+ total_masked: int = simple_total + kind_total
100
+ total_top1: int = simple_top1 + kind_top1
101
+ total_top5: int = simple_top5 + kind_top5
102
+
103
+ return {
104
+ "top1_accuracy": total_top1 / max(total_masked, 1),
105
+ "top5_accuracy": total_top5 / max(total_masked, 1),
106
+ "total_masked": total_masked,
107
+ "simple_top1_accuracy": simple_top1 / max(simple_total, 1),
108
+ "kind_top1_accuracy": kind_top1 / max(kind_total, 1),
109
+ }
110
+
111
+ @torch.no_grad()
112
+ def analyze_embeddings(self) -> list[dict[str, Any]]:
113
+ """Compare embeddings of the same key at different tree positions."""
114
+ self.model.eval()
115
+ results: list[dict[str, Any]] = []
116
+
117
+ test_pairs: list[dict[str, Any]] = [
118
+ {
119
+ "key": "spec",
120
+ "position_a": {"depth": 0},
121
+ "position_b": {"depth": 2},
122
+ },
123
+ {
124
+ "key": "name",
125
+ "position_a": {"depth": 1},
126
+ "position_b": {"depth": 1},
127
+ },
128
+ ]
129
+
130
+ for pair in test_pairs:
131
+ key_id: int = self.vocab.encode_key(pair["key"])
132
+
133
+ token_ids: torch.Tensor = torch.tensor(
134
+ [[key_id, key_id]], device=self.device
135
+ )
136
+ node_types: torch.Tensor = torch.tensor(
137
+ [[0, 0]], device=self.device
138
+ )
139
+ depths: torch.Tensor = torch.tensor(
140
+ [[pair["position_a"]["depth"], pair["position_b"]["depth"]]],
141
+ device=self.device,
142
+ )
143
+ siblings: torch.Tensor = torch.tensor(
144
+ [[0, 0]], device=self.device
145
+ )
146
+
147
+ embeddings: torch.Tensor = self.model.embedding(
148
+ token_ids, node_types, depths, siblings
149
+ )
150
+
151
+ cosine_sim: float = F.cosine_similarity(
152
+ embeddings[0, 0].unsqueeze(0),
153
+ embeddings[0, 1].unsqueeze(0),
154
+ ).item()
155
+
156
+ results.append({
157
+ "key": pair["key"],
158
+ "position_a": pair["position_a"],
159
+ "position_b": pair["position_b"],
160
+ "cosine_similarity": cosine_sim,
161
+ })
162
+
163
+ return results
164
+
165
+ @torch.no_grad()
166
+ def top_k_predictions(
167
+ self, doc_idx: int, k: int = 5
168
+ ) -> list[dict[str, Any]]:
169
+ """Show top-k predicted keys for each masked position in a document.
170
+
171
+ Reports predictions from both the simple and kind-specific heads.
172
+ """
173
+ self.model.eval()
174
+
175
+ item: dict[str, torch.Tensor] = self.dataset[doc_idx]
176
+ simple_labels: torch.Tensor = item["simple_labels"]
177
+ kind_labels: torch.Tensor = item["kind_labels"]
178
+
179
+ simple_masked: torch.Tensor = simple_labels != -100
180
+ kind_masked: torch.Tensor = kind_labels != -100
181
+ if not simple_masked.any() and not kind_masked.any():
182
+ return []
183
+
184
+ simple_logits, kind_logits = self.model(
185
+ token_ids=item["token_ids"].unsqueeze(0).to(self.device),
186
+ node_types=item["node_types"].unsqueeze(0).to(self.device),
187
+ depths=item["depths"].unsqueeze(0).to(self.device),
188
+ sibling_indices=item["sibling_indices"].unsqueeze(0).to(self.device),
189
+ )
190
+
191
+ predictions: list[dict[str, Any]] = []
192
+
193
+ # Simple head predictions
194
+ s_logits: torch.Tensor = simple_logits[0]
195
+ for pos in simple_masked.nonzero(as_tuple=True)[0]:
196
+ true_id: int = simple_labels[pos].item()
197
+ pos_logits: torch.Tensor = s_logits[pos]
198
+ probs: torch.Tensor = F.softmax(pos_logits, dim=-1)
199
+ topk: torch.return_types.topk = probs.topk(k)
200
+
201
+ predicted_keys: list[dict[str, Any]] = [
202
+ {
203
+ "key": self._decode_simple(topk.indices[i].item()),
204
+ "probability": topk.values[i].item(),
205
+ }
206
+ for i in range(k)
207
+ ]
208
+
209
+ predictions.append({
210
+ "position": pos.item(),
211
+ "head": "simple",
212
+ "true_key": self._decode_simple(true_id),
213
+ "predicted_keys": predicted_keys,
214
+ })
215
+
216
+ # Kind-specific head predictions
217
+ k_logits: torch.Tensor = kind_logits[0]
218
+ for pos in kind_masked.nonzero(as_tuple=True)[0]:
219
+ true_id = kind_labels[pos].item()
220
+ pos_logits = k_logits[pos]
221
+ probs = F.softmax(pos_logits, dim=-1)
222
+ topk = probs.topk(k)
223
+
224
+ predicted_keys = [
225
+ {
226
+ "key": self._decode_kind(topk.indices[i].item()),
227
+ "probability": topk.values[i].item(),
228
+ }
229
+ for i in range(k)
230
+ ]
231
+
232
+ predictions.append({
233
+ "position": pos.item(),
234
+ "head": "kind",
235
+ "true_key": self._decode_kind(true_id),
236
+ "predicted_keys": predicted_keys,
237
+ })
238
+
239
+ predictions.sort(key=lambda p: p["position"])
240
+ return predictions
yaml_bert/linearizer.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import yaml
4
+
5
+ from yaml_bert.types import NodeType, YamlNode
6
+
7
+
8
+ class YamlLinearizer:
9
+ def linearize(self, yaml_string: str) -> list[YamlNode]:
10
+ data = yaml.safe_load(yaml_string)
11
+ if data is None:
12
+ return []
13
+ nodes: list[YamlNode] = []
14
+ self._walk(data, depth=0, parent_path="", nodes=nodes, in_list=False)
15
+ return nodes
16
+
17
+ def _walk(
18
+ self,
19
+ data,
20
+ depth: int,
21
+ parent_path: str,
22
+ nodes: list[YamlNode],
23
+ in_list: bool,
24
+ ) -> None:
25
+ if isinstance(data, dict):
26
+ for sibling_index, (key, value) in enumerate(data.items()):
27
+ key_str = str(key)
28
+ key_type = NodeType.LIST_KEY if in_list else NodeType.KEY
29
+ nodes.append(
30
+ YamlNode(
31
+ token=key_str,
32
+ node_type=key_type,
33
+ depth=depth,
34
+ sibling_index=sibling_index,
35
+ parent_path=parent_path,
36
+ )
37
+ )
38
+ if isinstance(value, dict):
39
+ child_path = f"{parent_path}.{key_str}" if parent_path else key_str
40
+ self._walk(value, depth + 1, child_path, nodes, in_list=False)
41
+ elif isinstance(value, list):
42
+ child_path = f"{parent_path}.{key_str}" if parent_path else key_str
43
+ self._walk_list(value, depth + 1, child_path, nodes)
44
+ else:
45
+ value_path = f"{parent_path}.{key_str}" if parent_path else key_str
46
+ value_type = NodeType.LIST_VALUE if in_list else NodeType.VALUE
47
+ nodes.append(
48
+ YamlNode(
49
+ token=str(value),
50
+ node_type=value_type,
51
+ depth=depth,
52
+ sibling_index=sibling_index,
53
+ parent_path=value_path,
54
+ )
55
+ )
56
+
57
+ def linearize_file(self, path: str) -> list[YamlNode]:
58
+ with open(path) as f:
59
+ content = f.read()
60
+ nodes: list[YamlNode] = []
61
+ for doc in yaml.safe_load_all(content):
62
+ if doc is None:
63
+ continue
64
+ self._walk(doc, depth=0, parent_path="", nodes=nodes, in_list=False)
65
+ return nodes
66
+
67
+ def linearize_multi_doc(self, yaml_string: str) -> list[list[YamlNode]]:
68
+ result = []
69
+ for doc in yaml.safe_load_all(yaml_string):
70
+ if doc is None:
71
+ continue
72
+ nodes: list[YamlNode] = []
73
+ self._walk(doc, depth=0, parent_path="", nodes=nodes, in_list=False)
74
+ result.append(nodes)
75
+ return result
76
+
77
+ def _walk_list(
78
+ self,
79
+ data: list,
80
+ depth: int,
81
+ parent_path: str,
82
+ nodes: list[YamlNode],
83
+ ) -> None:
84
+ for item_index, item in enumerate(data):
85
+ item_path = f"{parent_path}.{item_index}"
86
+ if isinstance(item, dict):
87
+ self._walk(item, depth, item_path, nodes, in_list=True)
88
+ elif isinstance(item, list):
89
+ self._walk_list(item, depth, item_path, nodes)
90
+ else:
91
+ nodes.append(
92
+ YamlNode(
93
+ token=str(item),
94
+ node_type=NodeType.LIST_VALUE,
95
+ depth=depth,
96
+ sibling_index=item_index,
97
+ parent_path=item_path,
98
+ )
99
+ )
yaml_bert/model.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+
6
+ from yaml_bert.config import YamlBertConfig
7
+ from yaml_bert.embedding import YamlBertEmbedding
8
+
9
+
10
+ class YamlBertModel(nn.Module):
11
+ """YAML-BERT: Transformer encoder with tree positional encoding.
12
+
13
+ Two prediction heads: simple (bigram) + kind-specific (trigram).
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ config: YamlBertConfig,
19
+ embedding: YamlBertEmbedding,
20
+ simple_vocab_size: int,
21
+ kind_vocab_size: int,
22
+ ) -> None:
23
+ super().__init__()
24
+ self.embedding: YamlBertEmbedding = embedding
25
+
26
+ encoder_layer: nn.TransformerEncoderLayer = nn.TransformerEncoderLayer(
27
+ d_model=config.d_model,
28
+ nhead=config.num_heads,
29
+ dim_feedforward=config.d_ff,
30
+ batch_first=True,
31
+ )
32
+ self.encoder: nn.TransformerEncoder = nn.TransformerEncoder(
33
+ encoder_layer, num_layers=config.num_layers
34
+ )
35
+
36
+ self.simple_head: nn.Linear = nn.Linear(config.d_model, simple_vocab_size)
37
+ self.kind_head: nn.Linear = nn.Linear(config.d_model, kind_vocab_size)
38
+ self.loss_fn: nn.CrossEntropyLoss = nn.CrossEntropyLoss(ignore_index=-100)
39
+
40
+ def forward(
41
+ self,
42
+ token_ids: torch.Tensor,
43
+ node_types: torch.Tensor,
44
+ depths: torch.Tensor,
45
+ sibling_indices: torch.Tensor,
46
+ padding_mask: torch.Tensor | None = None,
47
+ ) -> tuple[torch.Tensor, torch.Tensor]:
48
+ x: torch.Tensor = self.embedding(token_ids, node_types, depths, sibling_indices)
49
+ x = self.encoder(x, src_key_padding_mask=padding_mask)
50
+ simple_logits: torch.Tensor = self.simple_head(x)
51
+ kind_logits: torch.Tensor = self.kind_head(x)
52
+ return simple_logits, kind_logits
53
+
54
+ def compute_loss(
55
+ self,
56
+ simple_logits: torch.Tensor,
57
+ simple_labels: torch.Tensor,
58
+ kind_logits: torch.Tensor,
59
+ kind_labels: torch.Tensor,
60
+ ) -> tuple[torch.Tensor, dict[str, float]]:
61
+ simple_loss: torch.Tensor = self.loss_fn(
62
+ simple_logits.view(-1, simple_logits.size(-1)),
63
+ simple_labels.view(-1),
64
+ )
65
+
66
+ # Kind loss: only compute if there are kind-specific labels in this batch
67
+ has_kind: bool = (kind_labels != -100).any().item()
68
+ if has_kind:
69
+ kind_loss: torch.Tensor = self.loss_fn(
70
+ kind_logits.view(-1, kind_logits.size(-1)),
71
+ kind_labels.view(-1),
72
+ )
73
+ else:
74
+ kind_loss = torch.tensor(0.0, device=simple_logits.device)
75
+
76
+ total: torch.Tensor = simple_loss + kind_loss
77
+ return total, {"simple": simple_loss.item(), "kind": kind_loss.item()}
yaml_bert/pooling.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+
7
+
8
+ class DocumentPooling(nn.Module):
9
+ """Pooling by Multi-head Attention.
10
+
11
+ The kind node queries all other nodes via cross-attention
12
+ to produce a single document embedding.
13
+ """
14
+
15
+ def __init__(self, d_model: int, num_heads: int = 4) -> None:
16
+ super().__init__()
17
+ self.query_proj: nn.Linear = nn.Linear(d_model, d_model)
18
+ self.cross_attn: nn.MultiheadAttention = nn.MultiheadAttention(
19
+ d_model, num_heads, batch_first=True,
20
+ )
21
+ self.layer_norm: nn.LayerNorm = nn.LayerNorm(d_model)
22
+
23
+ def forward(
24
+ self,
25
+ kind_hidden: torch.Tensor,
26
+ all_hidden: torch.Tensor,
27
+ ) -> torch.Tensor:
28
+ query: torch.Tensor = self.query_proj(kind_hidden)
29
+ doc_emb, _ = self.cross_attn(query, all_hidden, all_hidden)
30
+ doc_emb = self.layer_norm(doc_emb)
31
+ return doc_emb.squeeze(1)
32
+
33
+
34
+ def supervised_contrastive_loss(
35
+ embeddings: torch.Tensor,
36
+ labels: torch.Tensor,
37
+ temperature: float = 0.1,
38
+ ) -> torch.Tensor:
39
+ embeddings = F.normalize(embeddings, dim=1)
40
+ batch_size: int = embeddings.shape[0]
41
+
42
+ sim: torch.Tensor = embeddings @ embeddings.T / temperature
43
+
44
+ label_mask: torch.Tensor = labels.unsqueeze(0) == labels.unsqueeze(1)
45
+ self_mask: torch.Tensor = torch.eye(batch_size, dtype=torch.bool, device=embeddings.device)
46
+ label_mask = label_mask & ~self_mask
47
+
48
+ sim_max, _ = sim.max(dim=1, keepdim=True)
49
+ sim = sim - sim_max.detach()
50
+
51
+ exp_sim: torch.Tensor = torch.exp(sim) * (~self_mask).float()
52
+ log_prob: torch.Tensor = sim - torch.log(exp_sim.sum(dim=1, keepdim=True) + 1e-8)
53
+
54
+ pos_count: torch.Tensor = label_mask.float().sum(dim=1).clamp(min=1)
55
+ loss: torch.Tensor = -(label_mask.float() * log_prob).sum(dim=1) / pos_count
56
+
57
+ return loss.mean()
yaml_bert/similarity.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from yaml_bert.annotator import DomainAnnotator
9
+ from yaml_bert.linearizer import YamlLinearizer
10
+ from yaml_bert.model import YamlBertModel
11
+ from yaml_bert.pooling import DocumentPooling
12
+ from yaml_bert.types import NodeType, YamlNode
13
+ from yaml_bert.vocab import Vocabulary
14
+
15
+
16
+ _NODE_TYPE_INDEX: dict[NodeType, int] = {
17
+ NodeType.KEY: 0, NodeType.VALUE: 1,
18
+ NodeType.LIST_KEY: 2, NodeType.LIST_VALUE: 3,
19
+ }
20
+
21
+
22
+ @torch.no_grad()
23
+ def extract_hidden_states(
24
+ model: YamlBertModel,
25
+ vocab: Vocabulary,
26
+ yaml_text: str,
27
+ ) -> tuple[torch.Tensor, int]:
28
+ linearizer: YamlLinearizer = YamlLinearizer()
29
+ annotator: DomainAnnotator = DomainAnnotator()
30
+ # Use linearize_multi_doc to handle --- separators, take the first doc
31
+ docs: list[list[YamlNode]] = linearizer.linearize_multi_doc(yaml_text)
32
+ nodes: list[YamlNode] = docs[0] if docs else []
33
+ if not nodes:
34
+ return torch.empty(0), -1
35
+ annotator.annotate(nodes)
36
+
37
+ token_ids: list[int] = []
38
+ node_types: list[int] = []
39
+ depths: list[int] = []
40
+ siblings: list[int] = []
41
+ kind_pos: int = -1
42
+
43
+ for i, node in enumerate(nodes):
44
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
45
+ token_ids.append(vocab.encode_key(node.token))
46
+ else:
47
+ token_ids.append(vocab.encode_value(node.token))
48
+ node_types.append(_NODE_TYPE_INDEX[node.node_type])
49
+ depths.append(min(node.depth, 15))
50
+ siblings.append(min(node.sibling_index, 31))
51
+
52
+ if node.token == "kind" and node.depth == 0 and node.node_type == NodeType.KEY and kind_pos == -1:
53
+ kind_pos = i
54
+
55
+ t = lambda x: torch.tensor([x])
56
+ model.eval()
57
+
58
+ x: torch.Tensor = model.embedding(
59
+ t(token_ids), t(node_types), t(depths), t(siblings),
60
+ )
61
+ for layer in model.encoder.layers:
62
+ x = layer(x)
63
+
64
+ return x.squeeze(0), kind_pos
65
+
66
+
67
+ @torch.no_grad()
68
+ def get_document_embedding(
69
+ model: YamlBertModel,
70
+ pooling: DocumentPooling,
71
+ vocab: Vocabulary,
72
+ yaml_text: str,
73
+ ) -> torch.Tensor:
74
+ hidden, kind_pos = extract_hidden_states(model, vocab, yaml_text)
75
+ if hidden.shape[0] == 0 or kind_pos < 0:
76
+ return torch.zeros(hidden.shape[1] if hidden.dim() > 1 else 1)
77
+
78
+ pooling.eval()
79
+ kind_hidden: torch.Tensor = hidden[kind_pos].unsqueeze(0).unsqueeze(0)
80
+ all_hidden: torch.Tensor = hidden.unsqueeze(0)
81
+ return pooling(kind_hidden, all_hidden).squeeze(0)
82
+
83
+
84
+ def cosine_similarity_matrix(embeddings: torch.Tensor) -> torch.Tensor:
85
+ normed: torch.Tensor = F.normalize(embeddings, dim=1)
86
+ return normed @ normed.T
yaml_bert/suggest.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from yaml_bert.annotator import DomainAnnotator
9
+ from yaml_bert.dataset import _extract_kind
10
+ from yaml_bert.linearizer import YamlLinearizer
11
+ from yaml_bert.model import YamlBertModel
12
+ from yaml_bert.types import NodeType, YamlNode
13
+ from yaml_bert.vocab import Vocabulary, compute_target
14
+
15
+
16
+ # Keys managed by the cluster, not written by users
17
+ _CLUSTER_MANAGED_KEYS: set[str] = {
18
+ "status", "creationTimestamp", "generation", "resourceVersion",
19
+ "selfLink", "uid", "managedFields",
20
+ }
21
+
22
+ _NODE_TYPE_INDEX: dict[NodeType, int] = {
23
+ NodeType.KEY: 0,
24
+ NodeType.VALUE: 1,
25
+ NodeType.LIST_KEY: 2,
26
+ NodeType.LIST_VALUE: 3,
27
+ }
28
+
29
+
30
+ def suggest_missing_fields(
31
+ model: YamlBertModel,
32
+ vocab: Vocabulary,
33
+ yaml_text: str,
34
+ threshold: float = 0.3,
35
+ top_k: int = 10,
36
+ ) -> list[dict[str, Any]]:
37
+ """Suggest missing fields in a YAML document based on model conventions.
38
+
39
+ Uses the dual-head (simple + kind-specific) prediction approach.
40
+ For each parent level, a fake [MASK] node is appended and both heads
41
+ are consulted based on which head would apply at that tree position.
42
+
43
+ Args:
44
+ model: Trained YAML-BERT model
45
+ vocab: Vocabulary
46
+ yaml_text: Raw YAML text
47
+ threshold: Minimum confidence to report a missing field
48
+ top_k: Number of predictions per masked position
49
+
50
+ Returns:
51
+ List of suggestions sorted by confidence (highest first).
52
+ Each suggestion: {"parent_path": str, "missing_key": str, "confidence": float}
53
+ """
54
+ linearizer: YamlLinearizer = YamlLinearizer()
55
+ annotator: DomainAnnotator = DomainAnnotator()
56
+ nodes: list[YamlNode] = linearizer.linearize(yaml_text)
57
+ if not nodes:
58
+ return []
59
+ annotator.annotate(nodes)
60
+
61
+ token_ids, node_types, depths, siblings = _encode_nodes(nodes, vocab)
62
+
63
+ kind: str = _extract_kind(nodes)
64
+ mask_id: int = vocab.special_tokens["[MASK]"]
65
+
66
+ # Build reverse lookups for decoding predictions
67
+ id_to_simple: dict[int, str] = {v: k for k, v in vocab.simple_target_vocab.items()}
68
+ id_to_kind: dict[int, str] = {v: k for k, v in vocab.kind_target_vocab.items()}
69
+ id_to_special: dict[int, str] = {v: k for k, v in vocab.special_tokens.items()}
70
+
71
+ # Group key nodes by parent_path
72
+ keys_by_parent: dict[str, set[str]] = {}
73
+ key_positions_by_parent: dict[str, list[int]] = {}
74
+
75
+ for i, node in enumerate(nodes):
76
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
77
+ keys_by_parent.setdefault(node.parent_path, set()).add(node.token)
78
+ key_positions_by_parent.setdefault(node.parent_path, []).append(i)
79
+
80
+ # For each parent level, append a fake masked node as the "next sibling"
81
+ # to discover missing keys. Filter out noise: self-references, keys that
82
+ # already exist at root level, and special tokens.
83
+ model.eval()
84
+ predicted_keys_by_parent: dict[str, dict[str, float]] = {}
85
+ skipped_by_parent: dict[str, list[dict[str, Any]]] = {}
86
+
87
+ # Collect all existing keys across the entire document for cross-reference filtering
88
+ all_root_keys: set[str] = {
89
+ n.token for n in nodes
90
+ if n.node_type in (NodeType.KEY, NodeType.LIST_KEY) and n.depth == 0
91
+ }
92
+
93
+ t = lambda x: torch.tensor([x])
94
+
95
+ for parent_path, positions in key_positions_by_parent.items():
96
+ predicted: dict[str, float] = {}
97
+ # Extract parent key name, skipping numeric list indices
98
+ # e.g., "spec.containers.0" → "containers", not "0"
99
+ parent_key_name: str = ""
100
+ if parent_path:
101
+ for part in reversed(parent_path.split(".")):
102
+ if not part.isdigit():
103
+ parent_key_name = part
104
+ break
105
+
106
+ # Use the last key node at this level as a template for the fake node
107
+ last_pos: int = positions[-1]
108
+ last_node: YamlNode = nodes[last_pos]
109
+ next_sibling: int = min(last_node.sibling_index + 1, 31)
110
+
111
+ # Find the insertion point: right after the last sibling's subtree.
112
+ # Walk forward from last_pos until we find a node at the same or
113
+ # shallower depth (i.e., the subtree under last_pos has ended).
114
+ insert_pos: int = last_pos + 1
115
+ while insert_pos < len(token_ids):
116
+ if nodes[insert_pos].depth <= last_node.depth:
117
+ break
118
+ insert_pos += 1
119
+
120
+ # Insert a fake [MASK] node at the correct position in the sequence
121
+ fake_token_ids: list[int] = token_ids[:insert_pos] + [mask_id] + token_ids[insert_pos:]
122
+ fake_node_types: list[int] = node_types[:insert_pos] + [_NODE_TYPE_INDEX[last_node.node_type]] + node_types[insert_pos:]
123
+ fake_depths: list[int] = depths[:insert_pos] + [last_node.depth] + depths[insert_pos:]
124
+ fake_siblings: list[int] = siblings[:insert_pos] + [next_sibling] + siblings[insert_pos:]
125
+ fake_pos: int = insert_pos
126
+
127
+ # Determine which head applies for this position using compute_target logic.
128
+ # Create a fake YamlNode to probe which head to use.
129
+ fake_node = YamlNode(
130
+ token="__probe__",
131
+ node_type=last_node.node_type,
132
+ depth=last_node.depth,
133
+ sibling_index=next_sibling,
134
+ parent_path=last_node.parent_path,
135
+ )
136
+ _, head_type = compute_target(fake_node, kind)
137
+
138
+ with torch.no_grad():
139
+ simple_logits, kind_logits = model(
140
+ t(fake_token_ids), t(fake_node_types), t(fake_depths),
141
+ t(fake_siblings),
142
+ )
143
+
144
+ if head_type == "simple":
145
+ probs: torch.Tensor = F.softmax(simple_logits[0, fake_pos], dim=-1)
146
+ id_to_target = id_to_simple
147
+ else:
148
+ probs = F.softmax(kind_logits[0, fake_pos], dim=-1)
149
+ id_to_target = id_to_kind
150
+
151
+ topk = probs.topk(top_k + 5)
152
+
153
+ for j in range(topk.indices.shape[0]):
154
+ target_id: int = topk.indices[j].item()
155
+ prob: float = topk.values[j].item()
156
+
157
+ # Decode target string
158
+ if target_id in id_to_special:
159
+ target_name: str = id_to_special[target_id]
160
+ else:
161
+ target_name = id_to_target.get(target_id, "[UNK]")
162
+
163
+ # Extract the raw key name and validate parent from composite targets.
164
+ # Simple targets: "parent::key" or just "key" (root level)
165
+ # Kind targets: "Kind::parent::key"
166
+ parts: list[str] = target_name.split("::")
167
+ key_name: str = parts[-1]
168
+
169
+ # Validate that the predicted target's parent matches where we're probing.
170
+ # E.g., "spec::imagePullSecrets" should only be accepted when probing under spec,
171
+ # not under matchLabels or metadata.
172
+ if len(parts) >= 2:
173
+ predicted_parent: str = parts[-2]
174
+ if predicted_parent != parent_key_name:
175
+ skipped_by_parent.setdefault(parent_path, []).append({
176
+ "target": target_name,
177
+ "key": key_name,
178
+ "predicted_parent": predicted_parent,
179
+ "actual_parent": parent_key_name,
180
+ "confidence": prob,
181
+ })
182
+ continue
183
+
184
+ # Skip special tokens
185
+ if key_name in ("[PAD]", "[UNK]", "[MASK]"):
186
+ continue
187
+ # Skip self-referencing (key same as parent key name)
188
+ if key_name == parent_key_name:
189
+ continue
190
+ # Skip root-level keys suggested as children (e.g., roleRef under subjects)
191
+ if parent_path and key_name in all_root_keys and last_node.depth > 0:
192
+ continue
193
+
194
+ predicted[key_name] = prob
195
+
196
+ predicted_keys_by_parent[parent_path] = predicted
197
+
198
+ # Find missing keys
199
+ suggestions: list[dict[str, Any]] = []
200
+
201
+ for parent_path, predicted in predicted_keys_by_parent.items():
202
+ existing: set[str] = keys_by_parent.get(parent_path, set())
203
+ for key_name, confidence in predicted.items():
204
+ if (key_name not in existing
205
+ and key_name not in _CLUSTER_MANAGED_KEYS
206
+ and confidence >= threshold):
207
+ suggestions.append({
208
+ "parent_path": parent_path,
209
+ "missing_key": key_name,
210
+ "confidence": confidence,
211
+ })
212
+
213
+ suggestions.sort(key=lambda s: -s["confidence"])
214
+ return suggestions, skipped_by_parent
215
+
216
+
217
+ def _encode_nodes(
218
+ nodes: list[YamlNode],
219
+ vocab: Vocabulary,
220
+ ) -> tuple[list[int], list[int], list[int], list[int]]:
221
+ """Encode nodes to integer lists for model input."""
222
+ token_ids: list[int] = []
223
+ node_types: list[int] = []
224
+ depths: list[int] = []
225
+ siblings: list[int] = []
226
+
227
+ for node in nodes:
228
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
229
+ token_ids.append(vocab.encode_key(node.token))
230
+ else:
231
+ token_ids.append(vocab.encode_value(node.token))
232
+ node_types.append(_NODE_TYPE_INDEX[node.node_type])
233
+ depths.append(min(node.depth, 15))
234
+ siblings.append(min(node.sibling_index, 31))
235
+
236
+ return token_ids, node_types, depths, siblings
yaml_bert/trainer.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import torch
6
+ from torch.optim import AdamW
7
+ from torch.utils.data import DataLoader
8
+ from tqdm import tqdm
9
+
10
+ from yaml_bert.config import YamlBertConfig
11
+ from yaml_bert.dataset import YamlDataset, collate_fn
12
+ from yaml_bert.model import YamlBertModel
13
+
14
+
15
+ class YamlBertTrainer:
16
+ """Training loop with hybrid loss from two prediction heads."""
17
+
18
+ def __init__(
19
+ self,
20
+ config: YamlBertConfig,
21
+ model: YamlBertModel,
22
+ dataset: YamlDataset,
23
+ checkpoint_dir: str | None = None,
24
+ checkpoint_every: int = 1,
25
+ resume_from: str | None = None,
26
+ ) -> None:
27
+ self.config = config
28
+ self.model = model
29
+ self.dataset = dataset
30
+ self.checkpoint_dir = checkpoint_dir
31
+ self.checkpoint_every = checkpoint_every
32
+ self.resume_from = resume_from
33
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
34
+
35
+ def train(self) -> list[float]:
36
+ from datetime import datetime
37
+ self.model.to(self.device)
38
+ self.model.train()
39
+
40
+ print(f"Training started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
41
+ num_params = sum(p.numel() for p in self.model.parameters())
42
+ print(f"Model parameters: {num_params:,}")
43
+ print(f"Config: d_model={self.config.d_model}, layers={self.config.num_layers}, heads={self.config.num_heads}")
44
+ print(f"Device: {self.device}")
45
+
46
+ optimizer = AdamW(self.model.parameters(), lr=self.config.lr, weight_decay=0.01)
47
+ start_epoch = 0
48
+
49
+ if self.resume_from:
50
+ checkpoint = torch.load(self.resume_from, map_location=self.device)
51
+ self.model.load_state_dict(checkpoint["model_state_dict"])
52
+ optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
53
+ start_epoch = checkpoint["epoch"]
54
+ print(f"Resumed from epoch {start_epoch}")
55
+
56
+ dataloader = DataLoader(
57
+ self.dataset, batch_size=self.config.batch_size,
58
+ shuffle=True, collate_fn=collate_fn,
59
+ )
60
+
61
+ epoch_losses: list[float] = []
62
+ for epoch in range(start_epoch, self.config.num_epochs):
63
+ total_loss: float = 0.0
64
+ num_batches: int = 0
65
+ running_breakdown: dict[str, float] = {}
66
+
67
+ pbar = tqdm(dataloader, desc=f"Epoch {epoch+1}/{self.config.num_epochs}", leave=True)
68
+ for batch in pbar:
69
+ batch = {k: v.to(self.device) for k, v in batch.items()}
70
+ optimizer.zero_grad()
71
+
72
+ simple_logits, kind_logits = self.model(
73
+ token_ids=batch["token_ids"],
74
+ node_types=batch["node_types"],
75
+ depths=batch["depths"],
76
+ sibling_indices=batch["sibling_indices"],
77
+ padding_mask=batch["padding_mask"],
78
+ )
79
+
80
+ loss, breakdown = self.model.compute_loss(
81
+ simple_logits, batch["simple_labels"],
82
+ kind_logits, batch["kind_labels"],
83
+ )
84
+
85
+ if torch.isnan(loss):
86
+ continue # skip bad batches
87
+
88
+ loss.backward()
89
+ torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
90
+ optimizer.step()
91
+
92
+ total_loss += loss.item()
93
+ for k, v in breakdown.items():
94
+ running_breakdown[k] = running_breakdown.get(k, 0.0) + v
95
+ num_batches += 1
96
+
97
+ postfix = {"loss": f"{total_loss/num_batches:.4f}"}
98
+ for k in ["simple", "kind"]:
99
+ if k in running_breakdown:
100
+ postfix[k] = f"{running_breakdown[k]/num_batches:.4f}"
101
+ pbar.set_postfix(**postfix)
102
+
103
+ avg_loss: float = total_loss / max(num_batches, 1)
104
+ epoch_losses.append(avg_loss)
105
+ breakdown_str: str = " | ".join(
106
+ f"{k}: {v/max(num_batches,1):.4f}" for k, v in sorted(running_breakdown.items())
107
+ )
108
+ print(f"Epoch {epoch+1}/{self.config.num_epochs} — loss: {avg_loss:.4f} ({breakdown_str})")
109
+
110
+ if self.checkpoint_dir and (epoch+1) % self.checkpoint_every == 0:
111
+ self._save_checkpoint(epoch+1, optimizer)
112
+
113
+ if self.checkpoint_dir:
114
+ self._save_checkpoint(self.config.num_epochs, optimizer)
115
+
116
+ return epoch_losses
117
+
118
+ def _save_checkpoint(self, epoch: int, optimizer: AdamW) -> None:
119
+ os.makedirs(self.checkpoint_dir, exist_ok=True)
120
+ path = os.path.join(self.checkpoint_dir, f"yaml_bert_v4_epoch_{epoch}.pt")
121
+ torch.save({
122
+ "epoch": epoch,
123
+ "model_state_dict": self.model.state_dict(),
124
+ "optimizer_state_dict": optimizer.state_dict(),
125
+ "tree_pos_variant": self.config.tree_pos_variant.value,
126
+ }, path)
127
+ print(f"Checkpoint saved: {path}")
yaml_bert/types.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+
8
+ class NodeType(Enum):
9
+ KEY = "KEY"
10
+ VALUE = "VALUE"
11
+ LIST_KEY = "LIST_KEY"
12
+ LIST_VALUE = "LIST_VALUE"
13
+
14
+
15
+ @dataclass
16
+ class YamlNode:
17
+ token: str
18
+ node_type: NodeType
19
+ depth: int
20
+ sibling_index: int
21
+ parent_path: str
22
+ annotations: dict[str, Any] = field(default_factory=dict)
23
+
24
+ def __repr__(self) -> str:
25
+ return (
26
+ f"YamlNode({self.token!r}, {self.node_type.value}, "
27
+ f"depth={self.depth}, sibling={self.sibling_index}, "
28
+ f"path={self.parent_path!r})"
29
+ )
yaml_bert/visualize.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import matplotlib
6
+ matplotlib.use("Agg") # non-interactive backend
7
+ import matplotlib.pyplot as plt
8
+ import torch
9
+
10
+
11
+ def plot_training_loss(
12
+ losses: list[float],
13
+ output_path: str = "training_loss.png",
14
+ title: str = "YAML-BERT Training Loss",
15
+ ) -> None:
16
+ """Plot training loss curve over epochs."""
17
+ fig, ax = plt.subplots(figsize=(10, 6))
18
+ ax.plot(range(1, len(losses) + 1), losses, marker="o", linewidth=2)
19
+ ax.set_xlabel("Epoch")
20
+ ax.set_ylabel("Loss")
21
+ ax.set_title(title)
22
+ ax.grid(True, alpha=0.3)
23
+ fig.tight_layout()
24
+ fig.savefig(output_path, dpi=150)
25
+ plt.close(fig)
26
+ print(f"Training loss plot saved: {output_path}")
27
+
28
+
29
+ def plot_accuracy(
30
+ results: dict[str, float],
31
+ output_path: str = "accuracy.png",
32
+ title: str = "YAML-BERT Key Prediction Accuracy",
33
+ ) -> None:
34
+ """Plot top-1 and top-5 prediction accuracy as a bar chart."""
35
+ labels: list[str] = ["Top-1", "Top-5"]
36
+ values: list[float] = [results["top1_accuracy"], results["top5_accuracy"]]
37
+
38
+ fig, ax = plt.subplots(figsize=(6, 5))
39
+ bars = ax.bar(labels, values, color=["steelblue", "coral"], width=0.5)
40
+ ax.set_ylabel("Accuracy")
41
+ ax.set_title(title)
42
+ ax.set_ylim(0, 1)
43
+
44
+ for bar, val in zip(bars, values):
45
+ ax.text(
46
+ bar.get_x() + bar.get_width() / 2,
47
+ bar.get_height() + 0.02,
48
+ f"{val:.1%}",
49
+ ha="center",
50
+ fontsize=14,
51
+ fontweight="bold",
52
+ )
53
+
54
+ total: float = results.get("total_masked", 0)
55
+ ax.text(
56
+ 0.5, -0.1,
57
+ f"Total masked positions: {int(total)}",
58
+ ha="center",
59
+ transform=ax.transAxes,
60
+ fontsize=10,
61
+ color="gray",
62
+ )
63
+
64
+ fig.tight_layout()
65
+ fig.savefig(output_path, dpi=150)
66
+ plt.close(fig)
67
+ print(f"Accuracy plot saved: {output_path}")
68
+
69
+
70
+ def plot_embedding_similarity(
71
+ results: list[dict[str, Any]],
72
+ output_path: str = "embedding_similarity.png",
73
+ title: str = "Tree Position Embedding Similarity",
74
+ ) -> None:
75
+ """Plot cosine similarity between same-key embeddings at different tree positions."""
76
+ labels: list[str] = []
77
+ similarities: list[float] = []
78
+
79
+ for r in results:
80
+ pa: dict[str, Any] = r["position_a"]
81
+ pb: dict[str, Any] = r["position_b"]
82
+ label: str = (
83
+ f"{r['key']}\n"
84
+ f"d={pa['depth']},p={pa['parent_key']}\n"
85
+ f"vs d={pb['depth']},p={pb['parent_key']}"
86
+ )
87
+ labels.append(label)
88
+ similarities.append(r["cosine_similarity"])
89
+
90
+ fig, ax = plt.subplots(figsize=(max(8, len(results) * 3), 6))
91
+ bars = ax.bar(range(len(results)), similarities, color="steelblue")
92
+ ax.set_xticks(range(len(results)))
93
+ ax.set_xticklabels(labels, fontsize=9)
94
+ ax.set_ylabel("Cosine Similarity")
95
+ ax.set_title(title)
96
+ ax.set_ylim(-1, 1)
97
+ ax.axhline(y=0, color="gray", linestyle="--", alpha=0.5)
98
+
99
+ for bar, sim in zip(bars, similarities):
100
+ ax.text(
101
+ bar.get_x() + bar.get_width() / 2,
102
+ bar.get_height() + 0.02,
103
+ f"{sim:.3f}",
104
+ ha="center",
105
+ fontsize=10,
106
+ )
107
+
108
+ fig.tight_layout()
109
+ fig.savefig(output_path, dpi=150)
110
+ plt.close(fig)
111
+ print(f"Embedding similarity plot saved: {output_path}")
112
+
113
+
114
+ def plot_attention_patterns(
115
+ attention_weights: torch.Tensor,
116
+ token_labels: list[str],
117
+ output_path: str = "attention_patterns.png",
118
+ title: str = "Attention Patterns",
119
+ ) -> None:
120
+ """Plot attention heatmaps for each head.
121
+
122
+ Args:
123
+ attention_weights: (num_heads, seq_len, seq_len)
124
+ token_labels: labels for each position in the sequence
125
+ """
126
+ num_heads: int = attention_weights.shape[0]
127
+ fig, axes = plt.subplots(1, num_heads, figsize=(6 * num_heads, 5))
128
+
129
+ if num_heads == 1:
130
+ axes = [axes]
131
+
132
+ for head_idx, ax in enumerate(axes):
133
+ weights: torch.Tensor = attention_weights[head_idx].cpu()
134
+ im = ax.imshow(weights.numpy(), cmap="Blues", vmin=0, vmax=1)
135
+ ax.set_title(f"Head {head_idx}")
136
+ ax.set_xticks(range(len(token_labels)))
137
+ ax.set_yticks(range(len(token_labels)))
138
+ ax.set_xticklabels(token_labels, rotation=45, ha="right", fontsize=7)
139
+ ax.set_yticklabels(token_labels, fontsize=7)
140
+
141
+ fig.suptitle(title)
142
+ fig.tight_layout()
143
+ fig.savefig(output_path, dpi=150)
144
+ plt.close(fig)
145
+ print(f"Attention pattern plot saved: {output_path}")
yaml_bert/vocab.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from yaml_bert.types import NodeType, YamlNode
5
+
6
+
7
+ SPECIAL_TOKENS = ["[PAD]", "[UNK]", "[MASK]"]
8
+
9
+ UNIVERSAL_ROOT_KEYS: set[str] = {"apiVersion", "kind", "metadata"}
10
+
11
+ # Canonical casing for known Kubernetes kinds.
12
+ # Maps lowercase → canonical form.
13
+ _KIND_CANONICAL: dict[str, str] = {}
14
+
15
+
16
+ def normalize_kind(kind: str) -> str:
17
+ """Normalize kind value to canonical casing.
18
+
19
+ 'configmap' → 'ConfigMap', 'pod' → 'Pod', etc.
20
+ Unknown kinds are returned as-is.
21
+ """
22
+ return _KIND_CANONICAL.get(kind.lower(), kind)
23
+
24
+
25
+ def compute_target(node: YamlNode, kind: str) -> tuple[str, str]:
26
+ """Compute hybrid prediction target.
27
+
28
+ Returns (target_string, head_type) where head_type is "simple" or "kind_specific".
29
+ """
30
+ if node.depth == 0:
31
+ return node.token, "simple"
32
+
33
+ parent_key: str = Vocabulary.extract_parent_key(node.parent_path)
34
+
35
+ if node.depth == 1 and parent_key not in UNIVERSAL_ROOT_KEYS and parent_key != "":
36
+ return f"{kind}::{parent_key}::{node.token}", "kind_specific"
37
+
38
+ return (f"{parent_key}::{node.token}" if parent_key else node.token), "simple"
39
+
40
+
41
+ class Vocabulary:
42
+ def __init__(
43
+ self,
44
+ key_vocab: dict[str, int],
45
+ value_vocab: dict[str, int],
46
+ special_tokens: dict[str, int],
47
+ kind_vocab: dict[str, int] | None = None,
48
+ simple_target_vocab: dict[str, int] | None = None,
49
+ kind_target_vocab: dict[str, int] | None = None,
50
+ ) -> None:
51
+ self.key_vocab = key_vocab
52
+ self.value_vocab = value_vocab
53
+ self.special_tokens = special_tokens
54
+ self.kind_vocab = kind_vocab or {"[NO_KIND]": 0}
55
+ self.simple_target_vocab = simple_target_vocab or {}
56
+ self.kind_target_vocab = kind_target_vocab or {}
57
+ self._id_to_key = {v: k for k, v in key_vocab.items()}
58
+ self._id_to_value = {v: k for k, v in value_vocab.items()}
59
+ self._id_to_special = {v: k for k, v in special_tokens.items()}
60
+
61
+ def encode_key(self, token: str) -> int:
62
+ return self.key_vocab.get(token, self.special_tokens["[UNK]"])
63
+
64
+ def encode_value(self, token: str) -> int:
65
+ return self.value_vocab.get(token, self.special_tokens["[UNK]"])
66
+
67
+ def encode_kind(self, kind: str) -> int:
68
+ if not kind:
69
+ return self.kind_vocab["[NO_KIND]"]
70
+ return self.kind_vocab.get(kind, self.kind_vocab["[NO_KIND]"])
71
+
72
+ def decode_key(self, id: int) -> str:
73
+ if id in self._id_to_special:
74
+ return self._id_to_special[id]
75
+ return self._id_to_key.get(id, "[UNK]")
76
+
77
+ def decode_value(self, id: int) -> str:
78
+ if id in self._id_to_special:
79
+ return self._id_to_special[id]
80
+ return self._id_to_value.get(id, "[UNK]")
81
+
82
+ def encode_simple_target(self, target: str) -> int:
83
+ return self.simple_target_vocab.get(target, self.special_tokens["[UNK]"])
84
+
85
+ def encode_kind_target(self, target: str) -> int:
86
+ return self.kind_target_vocab.get(target, self.special_tokens["[UNK]"])
87
+
88
+ @staticmethod
89
+ def extract_parent_key(parent_path: str) -> str:
90
+ """Extract the last non-numeric component from a parent_path.
91
+
92
+ Examples:
93
+ "spec.template.spec.containers.0" -> "containers"
94
+ "metadata" -> "metadata"
95
+ "" -> ""
96
+ """
97
+ if not parent_path:
98
+ return ""
99
+ parts = parent_path.split(".")
100
+ for part in reversed(parts):
101
+ if not part.isdigit():
102
+ return part
103
+ return ""
104
+
105
+ @property
106
+ def key_vocab_size(self) -> int:
107
+ return len(self.key_vocab) + len(self.special_tokens)
108
+
109
+ @property
110
+ def value_vocab_size(self) -> int:
111
+ return len(self.value_vocab) + len(self.special_tokens)
112
+
113
+ @property
114
+ def kind_vocab_size(self) -> int:
115
+ return len(self.kind_vocab)
116
+
117
+ @property
118
+ def simple_target_vocab_size(self) -> int:
119
+ return len(self.simple_target_vocab) + len(self.special_tokens)
120
+
121
+ @property
122
+ def kind_target_vocab_size(self) -> int:
123
+ return len(self.kind_target_vocab) + len(self.special_tokens)
124
+
125
+ def save(self, path: str) -> None:
126
+ data = {
127
+ "key_vocab": self.key_vocab,
128
+ "value_vocab": self.value_vocab,
129
+ "special_tokens": self.special_tokens,
130
+ "kind_vocab": self.kind_vocab,
131
+ "simple_target_vocab": self.simple_target_vocab,
132
+ "kind_target_vocab": self.kind_target_vocab,
133
+ }
134
+ with open(path, "w") as f:
135
+ json.dump(data, f, indent=2)
136
+
137
+ @classmethod
138
+ def load(cls, path: str) -> Vocabulary:
139
+ with open(path) as f:
140
+ data = json.load(f)
141
+ return cls(
142
+ key_vocab=data["key_vocab"],
143
+ value_vocab=data["value_vocab"],
144
+ special_tokens=data["special_tokens"],
145
+ kind_vocab=data.get("kind_vocab"),
146
+ simple_target_vocab=data.get("simple_target_vocab", {}),
147
+ kind_target_vocab=data.get("kind_target_vocab", {}),
148
+ )
149
+
150
+
151
+ class VocabBuilder:
152
+ def build(self, nodes: list[YamlNode], min_freq: int = 1) -> Vocabulary:
153
+ key_counts: dict[str, int] = {}
154
+ value_counts: dict[str, int] = {}
155
+ kind_set: set[str] = set()
156
+ simple_target_counts: dict[str, int] = {}
157
+ kind_target_counts: dict[str, int] = {}
158
+
159
+ # First pass: collect all kind values to build canonical casing map
160
+ raw_kinds: dict[str, dict[str, int]] = {} # lowercase → {variant: count}
161
+ prev_was_kind_key = False
162
+ for node in nodes:
163
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
164
+ prev_was_kind_key = (node.token == "kind" and node.depth == 0)
165
+ elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
166
+ if prev_was_kind_key:
167
+ lower = node.token.lower()
168
+ raw_kinds.setdefault(lower, {})
169
+ raw_kinds[lower][node.token] = raw_kinds[lower].get(node.token, 0) + 1
170
+ prev_was_kind_key = False
171
+
172
+ # Build canonical map: most frequent casing wins
173
+ _KIND_CANONICAL.clear()
174
+ for lower, variants in raw_kinds.items():
175
+ canonical = max(variants, key=variants.get)
176
+ _KIND_CANONICAL[lower] = canonical
177
+
178
+ # Second pass: count tokens with normalized kinds
179
+ current_kind: str = ""
180
+ prev_was_kind_key = False
181
+ for node in nodes:
182
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
183
+ key_counts[node.token] = key_counts.get(node.token, 0) + 1
184
+ prev_was_kind_key = (node.token == "kind" and node.depth == 0)
185
+ target, head_type = compute_target(node, current_kind)
186
+ if head_type == "kind_specific":
187
+ kind_target_counts[target] = kind_target_counts.get(target, 0) + 1
188
+ else:
189
+ simple_target_counts[target] = simple_target_counts.get(target, 0) + 1
190
+ elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
191
+ value_counts[node.token] = value_counts.get(node.token, 0) + 1
192
+ if prev_was_kind_key:
193
+ normalized = normalize_kind(node.token)
194
+ kind_set.add(normalized)
195
+ current_kind = normalized
196
+ prev_was_kind_key = False
197
+
198
+ # Filter target vocabs by min_freq
199
+ simple_target_set: set[str] = {t for t, c in simple_target_counts.items() if c >= min_freq}
200
+ kind_target_set: set[str] = {t for t, c in kind_target_counts.items() if c >= min_freq}
201
+
202
+ return self.build_from_counts(
203
+ key_counts, value_counts, min_freq, kind_set,
204
+ simple_target_set, kind_target_set,
205
+ )
206
+
207
+ @staticmethod
208
+ def build_from_counts(
209
+ key_counts: dict[str, int],
210
+ value_counts: dict[str, int],
211
+ min_freq: int = 1,
212
+ kind_set: set[str] | None = None,
213
+ simple_target_set: set[str] | None = None,
214
+ kind_target_set: set[str] | None = None,
215
+ ) -> Vocabulary:
216
+ """Build vocabulary from pre-computed token counts."""
217
+ special_tokens = {tok: i for i, tok in enumerate(SPECIAL_TOKENS)}
218
+ offset = len(special_tokens)
219
+
220
+ key_vocab = {
221
+ token: i + offset
222
+ for i, token in enumerate(
223
+ sorted(t for t, c in key_counts.items() if c >= min_freq)
224
+ )
225
+ }
226
+
227
+ # Build value vocab: min_freq filtered, but always include kind values
228
+ value_tokens = set(t for t, c in value_counts.items() if c >= min_freq)
229
+ if kind_set:
230
+ value_tokens.update(kind_set) # kinds always in value vocab
231
+ value_vocab = {
232
+ token: i + offset
233
+ for i, token in enumerate(sorted(value_tokens))
234
+ }
235
+
236
+ kind_vocab: dict[str, int] = {"[NO_KIND]": 0}
237
+ for i, kind in enumerate(sorted(kind_set or [])):
238
+ kind_vocab[kind] = i + 1
239
+
240
+ simple_target_vocab = {
241
+ target: i + offset
242
+ for i, target in enumerate(sorted(simple_target_set or []))
243
+ }
244
+
245
+ kind_target_vocab = {
246
+ target: i + offset
247
+ for i, target in enumerate(sorted(kind_target_set or []))
248
+ }
249
+
250
+ return Vocabulary(key_vocab, value_vocab, special_tokens, kind_vocab,
251
+ simple_target_vocab, kind_target_vocab)
252
+
253
+ @staticmethod
254
+ def save_counts(
255
+ key_counts: dict[str, int],
256
+ value_counts: dict[str, int],
257
+ path: str,
258
+ ) -> None:
259
+ """Save raw token counts to a JSON file for reuse."""
260
+ with open(path, "w") as f:
261
+ json.dump({"key_counts": key_counts, "value_counts": value_counts}, f)
262
+ print(f"Token counts saved: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
263
+
264
+ @staticmethod
265
+ def load_counts(path: str) -> tuple[dict[str, int], dict[str, int]]:
266
+ """Load raw token counts from a JSON file."""
267
+ with open(path) as f:
268
+ data = json.load(f)
269
+ key_counts: dict[str, int] = data["key_counts"]
270
+ value_counts: dict[str, int] = data["value_counts"]
271
+ print(f"Token counts loaded: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
272
+ return key_counts, value_counts
273
+
274
+ def build_from_huggingface(
275
+ self,
276
+ dataset_name: str,
277
+ linearizer: "YamlLinearizer",
278
+ annotator: "DomainAnnotator",
279
+ max_docs: int | None = None,
280
+ min_freq: int = 1,
281
+ counts_path: str | None = None,
282
+ ) -> Vocabulary:
283
+ """Build vocabulary by scanning a HuggingFace dataset.
284
+
285
+ Args:
286
+ dataset_name: HuggingFace dataset ID
287
+ linearizer: YamlLinearizer instance
288
+ annotator: DomainAnnotator instance
289
+ max_docs: Scan at most this many docs for vocab (None = all)
290
+ min_freq: Minimum frequency to include a token
291
+ counts_path: If set, save raw counts here (and load if file exists)
292
+ """
293
+ import os
294
+
295
+ # Reuse saved counts if available
296
+ if counts_path and os.path.exists(counts_path):
297
+ key_counts, value_counts = self.load_counts(counts_path)
298
+ return self.build_from_counts(key_counts, value_counts, min_freq, None)
299
+
300
+ from datasets import load_dataset
301
+ from yaml_bert.types import YamlNode
302
+
303
+ print(f"Building vocabulary from: {dataset_name}")
304
+ ds = load_dataset(dataset_name, split="train")
305
+
306
+ total: int = len(ds) if max_docs is None else min(max_docs, len(ds))
307
+ print(f"Scanning {total:,} / {len(ds):,} documents for vocabulary...")
308
+
309
+ all_nodes: list[YamlNode] = []
310
+ skipped: int = 0
311
+ for i in range(total):
312
+ try:
313
+ nodes = linearizer.linearize(ds[i]["content"])
314
+ except Exception:
315
+ skipped += 1
316
+ continue
317
+ if nodes:
318
+ annotator.annotate(nodes)
319
+ all_nodes.extend(nodes)
320
+
321
+ if (i + 1) % 10000 == 0:
322
+ print(f" {i + 1:,} / {total:,} scanned ({len(all_nodes):,} nodes)")
323
+
324
+ print(f"Scanned {total - skipped:,} docs, {len(all_nodes):,} nodes ({skipped} skipped)")
325
+
326
+ # Compute counts
327
+ key_counts: dict[str, int] = {}
328
+ value_counts: dict[str, int] = {}
329
+ kind_set: set[str] = set()
330
+ prev_was_kind_key: bool = False
331
+ for node in all_nodes:
332
+ if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
333
+ key_counts[node.token] = key_counts.get(node.token, 0) + 1
334
+ prev_was_kind_key = (node.token == "kind" and node.depth == 0)
335
+ elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
336
+ value_counts[node.token] = value_counts.get(node.token, 0) + 1
337
+ if prev_was_kind_key:
338
+ kind_set.add(node.token)
339
+ prev_was_kind_key = False
340
+
341
+ # Save counts for reuse
342
+ if counts_path:
343
+ self.save_counts(key_counts, value_counts, counts_path)
344
+
345
+ return self.build_from_counts(key_counts, value_counts, min_freq, kind_set)