vimalk78 commited on
Commit
3457b3c
·
verified ·
1 Parent(s): 77a6c78

feat(v9): sub-tokenization — [UNK] collisions fixed + namespace probe passes + apiVersion probe added

Browse files
__pycache__/app.cpython-314.pyc CHANGED
Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ
 
app.py CHANGED
@@ -51,22 +51,20 @@ DEFAULT_VOCAB = "model/vocab.json"
51
  def load_model(checkpoint_path: str, vocab_path: str) -> tuple[YamlBertModel, Vocabulary]:
52
  _log(f"Loading vocab from {vocab_path}")
53
  vocab = Vocabulary.load(vocab_path)
54
- _log(f"Vocab loaded: {vocab.key_vocab_size} keys, "
55
- f"{vocab.value_vocab_size} values, "
56
- f"{vocab.atomic_target_vocab_size} atomic targets")
57
 
58
  _log(f"Reading checkpoint file {checkpoint_path}")
59
  cp = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
60
 
61
- _log("Building YamlBertModel architecture")
62
- # recon_enabled=True is required to load checkpoints from MLM+recon training
63
- # (state_dict includes recon_head weights). The recon head exists but is
64
- # never invoked at inference time (no subtree_roots_flat passed in forward).
65
  config = YamlBertConfig(recon_enabled=True)
66
  emb = YamlBertEmbedding(
67
  config=config,
68
- key_vocab_size=vocab.key_vocab_size,
69
- value_vocab_size=vocab.value_vocab_size,
70
  )
71
  model = YamlBertModel(
72
  config=config,
@@ -504,6 +502,80 @@ spec:
504
  - containerPort: 80
505
  """
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
 
508
  def _verdict_init(cos):
509
  cd = float(cos[2][3])
@@ -513,10 +585,17 @@ def _verdict_init(cos):
513
  f"`cos(C, D)` = **{cd:.3f}** (both have init) · "
514
  f"`max(cos(A,C), cos(B,D))` = **{mx:.3f}** (mixed). "
515
  + ("Init-pairs cluster tighter than mixed pairs — the model treats "
516
- "`initContainers` as a real structural feature."
 
517
  if passed else
518
- "Mixed pairs are at least as close as init-pairs the model "
519
- "does not cleanly separate Pods by `initContainers` presence here.")
 
 
 
 
 
 
520
  )
521
  return passed, msg
522
 
@@ -547,15 +626,48 @@ def _verdict_namespace(cos):
547
  msg = (
548
  f"`min(same-ns cos)` = **{same_ns:.3f}** · "
549
  f"`max(cross-ns cos)` = **{cross_ns:.3f}**. "
550
- + ("Same-namespace pairs cluster tighter — the model has somehow "
551
- "encoded namespace as a feature (surprising, since namespace is "
552
- "a leaf VALUE per the design rationale)."
 
 
 
553
  if passed else
554
  "Same-namespace and cross-namespace pairs have indistinguishable "
555
- "cosines the model is value-blind for namespace. This matches "
556
- "the design: values are second-class in `doc_vec`. Compare with "
557
- "the Service-type probe (passes) — `type` values matter because "
558
- "they bring structural KEYS along, while namespace values don't.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  )
560
  return passed, msg
561
 
@@ -620,10 +732,14 @@ PRESETS = [
620
  "If the model encodes `metadata.namespace` as a feature, two "
621
  "Pods in the same namespace should be closer than two Pods in "
622
  "different namespaces (controlling for structure). "
623
- "_Honest prediction: this should **fail** namespace is a "
624
- "leaf value, and values are second-class in `doc_vec`. The "
625
- "failure would confirm the design rationale; a pass would "
626
- "refute it._"
 
 
 
 
627
  ),
628
  "manifests": [
629
  {"name": "production / web-1", "yaml": _POD_NS_PROD_1},
@@ -633,6 +749,32 @@ PRESETS = [
633
  ],
634
  "verdict_fn": _verdict_namespace,
635
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  {
637
  "id": "cross-kind",
638
  "title": "Pod vs Deployment wrapping the same Pod",
@@ -698,6 +840,8 @@ def _encode_doc_vec(yaml_text: str) -> torch.Tensor:
698
  sibling_indices=batch["sibling_indices"],
699
  batch_info=batch["batch_info"],
700
  padding_mask=batch["padding_mask"],
 
 
701
  parent_of_tensor=batch["parent_of_tensor"],
702
  top_level_key_mask=batch["top_level_key_mask"],
703
  edges_by_depth=batch["edges_by_depth"],
@@ -722,6 +866,9 @@ def _layout_2d(vecs):
722
  dist = 1.0 - cos
723
  np.fill_diagonal(dist, 0.0)
724
  dist = np.clip(dist, 0.0, None)
 
 
 
725
 
726
  from sklearn.manifold import MDS
727
  mds = MDS(
@@ -1198,7 +1345,7 @@ Trained with MLM + reconstruction on 276K K8s manifests ·
1198
  value=EXAMPLE_NGINX,
1199
  )
1200
  threshold = gr.Slider(
1201
- minimum=0.05, maximum=0.95, value=0.1, step=0.05,
1202
  label="Confidence threshold",
1203
  )
1204
  submit = gr.Button("Suggest missing fields", variant="primary")
 
51
  def load_model(checkpoint_path: str, vocab_path: str) -> tuple[YamlBertModel, Vocabulary]:
52
  _log(f"Loading vocab from {vocab_path}")
53
  vocab = Vocabulary.load(vocab_path)
54
+ _log(f"Vocab loaded: subword={vocab.subword_vocab_size}, "
55
+ f"atomic targets={vocab.atomic_target_vocab_size}")
 
56
 
57
  _log(f"Reading checkpoint file {checkpoint_path}")
58
  cp = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
59
 
60
+ _log("Building YamlBertModel architecture (v9: subword embedding)")
61
+ # recon_enabled=True keeps the checkpoint's recon_head weights loadable.
62
+ # The recon head exists but is never invoked at inference time
63
+ # (no subtree_roots_flat passed in forward).
64
  config = YamlBertConfig(recon_enabled=True)
65
  emb = YamlBertEmbedding(
66
  config=config,
67
+ subword_vocab_size=vocab.subword_vocab_size,
 
68
  )
69
  model = YamlBertModel(
70
  config=config,
 
502
  - containerPort: 80
503
  """
504
 
505
+ _DEPLOY_APPS_V1 = """apiVersion: apps/v1
506
+ kind: Deployment
507
+ metadata:
508
+ name: web
509
+ spec:
510
+ replicas: 3
511
+ selector:
512
+ matchLabels:
513
+ app: web
514
+ template:
515
+ metadata:
516
+ labels:
517
+ app: web
518
+ spec:
519
+ containers:
520
+ - name: app
521
+ image: nginx
522
+ ports:
523
+ - containerPort: 80
524
+ """
525
+
526
+ _DEPLOY_EXT_V1BETA1 = """apiVersion: extensions/v1beta1
527
+ kind: Deployment
528
+ metadata:
529
+ name: web
530
+ spec:
531
+ replicas: 3
532
+ selector:
533
+ matchLabels:
534
+ app: web
535
+ template:
536
+ metadata:
537
+ labels:
538
+ app: web
539
+ spec:
540
+ containers:
541
+ - name: app
542
+ image: nginx
543
+ ports:
544
+ - containerPort: 80
545
+ """
546
+
547
+ _REPLICASET_APPS_V1 = """apiVersion: apps/v1
548
+ kind: ReplicaSet
549
+ metadata:
550
+ name: web
551
+ spec:
552
+ replicas: 3
553
+ selector:
554
+ matchLabels:
555
+ app: web
556
+ template:
557
+ metadata:
558
+ labels:
559
+ app: web
560
+ spec:
561
+ containers:
562
+ - name: app
563
+ image: nginx
564
+ ports:
565
+ - containerPort: 80
566
+ """
567
+
568
+ _CONFIGMAP_PLAIN = """apiVersion: v1
569
+ kind: ConfigMap
570
+ metadata:
571
+ name: web
572
+ data:
573
+ config.yaml: |
574
+ debug: true
575
+ app.properties: |
576
+ key=value
577
+ """
578
+
579
 
580
  def _verdict_init(cos):
581
  cd = float(cos[2][3])
 
585
  f"`cos(C, D)` = **{cd:.3f}** (both have init) · "
586
  f"`max(cos(A,C), cos(B,D))` = **{mx:.3f}** (mixed). "
587
  + ("Init-pairs cluster tighter than mixed pairs — the model treats "
588
+ "`initContainers` as a real structural feature, stronger than the "
589
+ "value-content similarity (shared `image: nginx` etc.)."
590
  if passed else
591
+ "Mixed pairs are at least as close as init-pairs. This is not a "
592
+ "regression it reveals a re-balance: BPE makes `image` values "
593
+ "compositionally visible to attention, so pods sharing `nginx` "
594
+ "cluster together regardless of init presence. v8 with atomic "
595
+ "`[UNK]` values had no choice but to lean on structure; v9 has "
596
+ "both signals and now weights content more heavily here. "
597
+ "Whether that's good depends on use case (good for content "
598
+ "retrieval, less ideal for structure-only similarity).")
599
  )
600
  return passed, msg
601
 
 
626
  msg = (
627
  f"`min(same-ns cos)` = **{same_ns:.3f}** · "
628
  f"`max(cross-ns cos)` = **{cross_ns:.3f}**. "
629
+ + ("Same-namespace pairs cluster tighter — value content reaches "
630
+ "`doc_vec` even though the aggregator only sums KEY subtrees. "
631
+ "BPE makes namespace values compositional (e.g., `prod | uction`), "
632
+ "and self-attention spreads that signal into neighboring KEY "
633
+ "hidden states, which then flow into `doc_vec`. This was the "
634
+ "first failure of `[UNK]`-vocab v8 that v9 fixed."
635
  if passed else
636
  "Same-namespace and cross-namespace pairs have indistinguishable "
637
+ "cosines. v8 saw this because both `production` and `staging` "
638
+ "often hit `[UNK]`. v9 was expected to pass if it does not, "
639
+ "investigate.")
640
+ )
641
+ return passed, msg
642
+
643
+
644
+ def _verdict_apiversion(cos):
645
+ # A = apps/v1 Deployment
646
+ # B = extensions/v1beta1 Deployment (same kind, deprecated apiVersion)
647
+ # C = apps/v1 ReplicaSet (different kind, same group, similar structure)
648
+ # D = v1 ConfigMap (unrelated)
649
+ same_kind = float(cos[0][1])
650
+ same_group_diff_kind = float(cos[0][2])
651
+ unrelated = float(cos[0][3])
652
+ primary = same_kind > same_group_diff_kind
653
+ secondary = same_group_diff_kind > unrelated
654
+ passed = primary and secondary
655
+ msg = (
656
+ f"`cos(apps/v1 Dep, ext/v1beta1 Dep)` = **{same_kind:.3f}** · "
657
+ f"`cos(Dep, ReplicaSet)` = **{same_group_diff_kind:.3f}** · "
658
+ f"`cos(Dep, ConfigMap)` = **{unrelated:.3f}**. "
659
+ + ("Same-kind-different-apiVersion pairs cluster tightest, then "
660
+ "same-group-different-kind, then unrelated. The model treats "
661
+ "apiVersion as a soft label, not a hard discriminator — it "
662
+ "recognizes `apps/v1` and `extensions/v1beta1` Deployments as "
663
+ "the same thing despite the apiVersion text differing."
664
+ if passed else
665
+ f"Expected ordering broken: same-kind={same_kind:.3f}, "
666
+ f"same-group={same_group_diff_kind:.3f}, unrelated={unrelated:.3f}. "
667
+ "If same-kind is NOT the tightest, the model is treating "
668
+ "apiVersion as a hard discriminator and separating same-kind "
669
+ "manifests by their api label — possibly an over-correction "
670
+ "from the eval-probe accuracy.")
671
  )
672
  return passed, msg
673
 
 
732
  "If the model encodes `metadata.namespace` as a feature, two "
733
  "Pods in the same namespace should be closer than two Pods in "
734
  "different namespaces (controlling for structure). "
735
+ "_v8 with atomic vocab failed this probe `production` and "
736
+ "`staging` often mapped to `[UNK]`, leaving attention with no "
737
+ "compositional content to work with. v9's byte-level BPE "
738
+ "decomposes namespace values into subwords, and self-attention "
739
+ "now spreads value content into surrounding KEY hidden states. "
740
+ "Those KEYs are what the aggregator pools into `doc_vec` — so "
741
+ "namespace effectively reaches `doc_vec` through the attention "
742
+ "channel, even though the aggregator stays KEY-only by design._"
743
  ),
744
  "manifests": [
745
  {"name": "production / web-1", "yaml": _POD_NS_PROD_1},
 
749
  ],
750
  "verdict_fn": _verdict_namespace,
751
  },
752
+ {
753
+ "id": "apiversion",
754
+ "title": "apiVersion sensitivity (same kind, different apiVersion)",
755
+ "hypothesis": (
756
+ "K8s often supports multiple `apiVersion`s for the same kind "
757
+ "(e.g., `apps/v1` Deployment and the deprecated "
758
+ "`extensions/v1beta1` Deployment have nearly identical structure). "
759
+ "If the model understands kind as the structural identity "
760
+ "and apiVersion as a soft label, two same-kind manifests "
761
+ "with different apiVersions should still cluster tighter than "
762
+ "a same-group different-kind manifest (`apps/v1` ReplicaSet), "
763
+ "which in turn should be tighter than an unrelated kind "
764
+ "(`v1` ConfigMap). _The eval probes already show apiVersion "
765
+ "classification at 99.8% — but classification accuracy is "
766
+ "compatible with either understanding the relationship or "
767
+ "treating apiVersion as a hard discriminator. This probe "
768
+ "tests which._"
769
+ ),
770
+ "manifests": [
771
+ {"name": "apps/v1 Deployment", "yaml": _DEPLOY_APPS_V1},
772
+ {"name": "extensions/v1beta1 Deploy", "yaml": _DEPLOY_EXT_V1BETA1},
773
+ {"name": "apps/v1 ReplicaSet", "yaml": _REPLICASET_APPS_V1},
774
+ {"name": "v1 ConfigMap (unrelated)", "yaml": _CONFIGMAP_PLAIN},
775
+ ],
776
+ "verdict_fn": _verdict_apiversion,
777
+ },
778
  {
779
  "id": "cross-kind",
780
  "title": "Pod vs Deployment wrapping the same Pod",
 
840
  sibling_indices=batch["sibling_indices"],
841
  batch_info=batch["batch_info"],
842
  padding_mask=batch["padding_mask"],
843
+ logical_ids=batch["logical_ids"],
844
+ n_logical_per_doc=batch["n_logical_per_doc"],
845
  parent_of_tensor=batch["parent_of_tensor"],
846
  top_level_key_mask=batch["top_level_key_mask"],
847
  edges_by_depth=batch["edges_by_depth"],
 
866
  dist = 1.0 - cos
867
  np.fill_diagonal(dist, 0.0)
868
  dist = np.clip(dist, 0.0, None)
869
+ # sklearn MDS(metric="precomputed") requires strict symmetry; floating-point
870
+ # matmul can leave ~1e-7 asymmetries. Symmetrize explicitly.
871
+ dist = (dist + dist.T) / 2
872
 
873
  from sklearn.manifold import MDS
874
  mds = MDS(
 
1345
  value=EXAMPLE_NGINX,
1346
  )
1347
  threshold = gr.Slider(
1348
+ minimum=0.05, maximum=0.95, value=0.7, step=0.05,
1349
  label="Confidence threshold",
1350
  )
1351
  submit = gr.Button("Suggest missing fields", variant="primary")
galaxy_data.json CHANGED
The diff for this file is too large to render. See raw diff
 
model/vocab.json CHANGED
The diff for this file is too large to render. See raw diff
 
model/yaml_bert.pt CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:13d86c864f0728e2c92edbbe2622463974201c21a4515c5df8f70131f9e6d1e9
3
- size 90137468
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5663d31145fad1b0f3d842624f5ed08b24c2bff5bfb42e77682c0877741b2c9c
3
+ size 74032259
tokenizers/v9_unified_bpe_8k.json ADDED
The diff for this file is too large to render. See raw diff
 
yaml_bert/aggregator.py CHANGED
@@ -1,11 +1,5 @@
1
- """Tree aggregator: bottom-up combine of token hidden states into subtree
2
- vectors + a document vector at the root.
3
-
4
- Mean combine (no learnable params). Two execution paths share the same
5
- output: a per-document reference loop, and a batched scatter-based
6
- vectorized path activated when the caller passes precomputed tensors.
7
- Numerical equivalence between the two paths is locked by
8
- tests/test_aggregator_vectorized.py.
9
  """
10
  from __future__ import annotations
11
 
@@ -13,19 +7,59 @@ import torch
13
  import torch.nn as nn
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  class TreeAggregator(nn.Module):
17
- """Bottom-up tree aggregation with mean combine.
18
 
19
  Two execution paths:
20
  - Reference path (default): per-doc Python loop. Used when the batch
21
- doesn't provide vectorized precompute tensors. Kept for tests and
22
- for backward compatibility.
23
- - Vectorized path (preferred during training): batched PyTorch scatter
24
- ops, processed depth-by-depth. Activated when caller passes the
25
- precomputed tensors as kwargs.
26
-
27
- Both paths produce numerically equivalent output on the same inputs
28
- (guaranteed by tests/test_aggregator_vectorized.py).
29
  """
30
 
31
  def __init__(self, d_model: int) -> None:
@@ -37,6 +71,8 @@ class TreeAggregator(nn.Module):
37
  hidden_states: torch.Tensor,
38
  batch_info: list[dict],
39
  *,
 
 
40
  parent_of_tensor: torch.Tensor | None = None,
41
  top_level_key_mask: torch.Tensor | None = None,
42
  edges_by_depth: dict[int, torch.Tensor] | None = None,
@@ -45,19 +81,21 @@ class TreeAggregator(nn.Module):
45
  ) -> tuple[torch.Tensor, torch.Tensor]:
46
  """
47
  Args:
48
- hidden_states: (B, N, d_model) per-position hidden states.
49
- batch_info: list of B dicts (legacy path; required even when
50
- vectorized kept for fallback compat).
 
51
  parent_of_tensor / top_level_key_mask / edges_by_depth /
52
  parents_by_depth: when ALL provided, use vectorized path.
53
- subtree_mask: (B, N) bool tensor. When provided, positions where
54
- subtree_mask[b, i]=True are excluded from sums into doc_vec
55
- and from contributing to ancestor subtree_vecs. Used for the
56
- v8 reconstruction objective's leak-prevention.
57
 
58
  Returns:
59
- (subtree_vecs, doc_vec)
 
60
  """
 
 
61
  provided = (
62
  parent_of_tensor is not None,
63
  top_level_key_mask is not None,
@@ -75,16 +113,19 @@ class TreeAggregator(nn.Module):
75
  f"parents_by_depth={'set' if provided[3] else 'None'}"
76
  )
77
  return self._forward_vectorized(
78
- hidden_states,
79
  top_level_key_mask=top_level_key_mask,
80
  edges_by_depth=edges_by_depth,
81
  parents_by_depth=parents_by_depth,
82
  subtree_mask=subtree_mask,
83
  )
84
  return self._forward_reference(
85
- hidden_states, batch_info, subtree_mask=subtree_mask,
86
  )
87
 
 
 
 
88
  def _forward_reference(
89
  self,
90
  hidden_states: torch.Tensor,
 
1
+ """Tree aggregator v9: pool subwords per logical node, then bottom-up
2
+ combine of logical KEY nodes into subtree vectors + a document vector.
 
 
 
 
 
 
3
  """
4
  from __future__ import annotations
5
 
 
7
  import torch.nn as nn
8
 
9
 
10
+ def _pool_subwords(
11
+ hidden_states: torch.Tensor,
12
+ logical_ids: torch.Tensor,
13
+ n_logical_per_doc: torch.Tensor,
14
+ ) -> torch.Tensor:
15
+ """Mean-pool subword hidden states into per-logical-node vectors.
16
+
17
+ Args:
18
+ hidden_states: (B, N_sub, d) per-subword hidden states from the encoder.
19
+ logical_ids: (B, N_sub) int tensor; -1 marks padding (ignored).
20
+ n_logical_per_doc: (B,) number of logical nodes per doc; pooled output
21
+ shape is (B, max(n_logical_per_doc), d).
22
+
23
+ Returns:
24
+ (B, L_max, d) where L_max = int(n_logical_per_doc.max()).
25
+ """
26
+ B, N_sub, d = hidden_states.shape
27
+ L_max = int(n_logical_per_doc.max().item())
28
+ out = torch.zeros(B, L_max, d, device=hidden_states.device, dtype=hidden_states.dtype)
29
+ count = torch.zeros(B, L_max, device=hidden_states.device, dtype=torch.float32)
30
+
31
+ valid = logical_ids >= 0 # (B, N_sub)
32
+ safe_lids = logical_ids.clamp(min=0) # (B, N_sub)
33
+
34
+ # Doc index broadcast over N_sub
35
+ doc_idx = torch.arange(B, device=hidden_states.device).unsqueeze(1).expand(B, N_sub)
36
+
37
+ # Linear (doc, logical) → flat slot
38
+ flat = doc_idx * L_max + safe_lids # (B, N_sub)
39
+ flat_valid = flat[valid]
40
+ h_valid = hidden_states[valid]
41
+
42
+ out_flat = out.view(B * L_max, d)
43
+ count_flat = count.view(B * L_max)
44
+ out_flat.index_add_(0, flat_valid, h_valid)
45
+ count_flat.index_add_(
46
+ 0, flat_valid, torch.ones_like(flat_valid, dtype=torch.float32),
47
+ )
48
+
49
+ pooled = out_flat / count_flat.clamp(min=1.0).unsqueeze(-1).to(out_flat.dtype)
50
+ return pooled.view(B, L_max, d)
51
+
52
+
53
  class TreeAggregator(nn.Module):
54
+ """v9: pool subwords first, then run v8 logical-level aggregator.
55
 
56
  Two execution paths:
57
  - Reference path (default): per-doc Python loop. Used when the batch
58
+ doesn't provide vectorized precompute tensors. Kept for tests.
59
+ - Vectorized path: batched scatter ops, processed depth-by-depth.
60
+
61
+ Both paths produce numerically equivalent output (guaranteed by
62
+ tests/test_aggregator_vectorized.py).
 
 
 
63
  """
64
 
65
  def __init__(self, d_model: int) -> None:
 
71
  hidden_states: torch.Tensor,
72
  batch_info: list[dict],
73
  *,
74
+ logical_ids: torch.Tensor,
75
+ n_logical_per_doc: torch.Tensor,
76
  parent_of_tensor: torch.Tensor | None = None,
77
  top_level_key_mask: torch.Tensor | None = None,
78
  edges_by_depth: dict[int, torch.Tensor] | None = None,
 
81
  ) -> tuple[torch.Tensor, torch.Tensor]:
82
  """
83
  Args:
84
+ hidden_states: (B, N_sub, d) per-subword hidden states from encoder.
85
+ logical_ids: (B, N_sub) per-subword logical-node id (-1 for pad).
86
+ n_logical_per_doc: (B,) number of logical nodes per doc.
87
+ batch_info: list of B dicts (legacy path; required for reference path).
88
  parent_of_tensor / top_level_key_mask / edges_by_depth /
89
  parents_by_depth: when ALL provided, use vectorized path.
90
+ subtree_mask: (B, L_max) bool; positions excluded from doc_vec and
91
+ ancestor subtree_vecs (used by v8 reconstruction objective).
 
 
92
 
93
  Returns:
94
+ (subtree_vecs, doc_vec) where subtree_vecs is (B, L_max, d) —
95
+ indexed by LOGICAL position, not subword.
96
  """
97
+ pooled = _pool_subwords(hidden_states, logical_ids, n_logical_per_doc)
98
+
99
  provided = (
100
  parent_of_tensor is not None,
101
  top_level_key_mask is not None,
 
113
  f"parents_by_depth={'set' if provided[3] else 'None'}"
114
  )
115
  return self._forward_vectorized(
116
+ pooled,
117
  top_level_key_mask=top_level_key_mask,
118
  edges_by_depth=edges_by_depth,
119
  parents_by_depth=parents_by_depth,
120
  subtree_mask=subtree_mask,
121
  )
122
  return self._forward_reference(
123
+ pooled, batch_info, subtree_mask=subtree_mask,
124
  )
125
 
126
+ # === _forward_reference and _forward_vectorized are verbatim from v8 ===
127
+ # They operate on per-position hidden states (now logical positions).
128
+
129
  def _forward_reference(
130
  self,
131
  hidden_states: torch.Tensor,
yaml_bert/config.py CHANGED
@@ -31,7 +31,7 @@ class YamlBertConfig:
31
  lr: float = 1e-4
32
  batch_size: int = 32
33
  num_epochs: int = 30
34
- max_seq_len: int = 512
35
 
36
  # Reconstruction objective (subtree masking + bag-of-keys prediction)
37
  recon_enabled: bool = False
 
31
  lr: float = 1e-4
32
  batch_size: int = 32
33
  num_epochs: int = 30
34
+ max_seq_len: int = 768 # was 512 in v8; v9 BPE-expands sequences ~2.3x
35
 
36
  # Reconstruction objective (subtree masking + bag-of-keys prediction)
37
  recon_enabled: bool = False
yaml_bert/dataset.py CHANGED
@@ -1,4 +1,5 @@
1
- """YAML-BERT dataset: children-info precompute for tree aggregator."""
 
2
  from __future__ import annotations
3
 
4
  import random
@@ -17,37 +18,11 @@ _LIST_INDEX_RE = re.compile(r"\.\d+$")
17
 
18
 
19
  def _strip_trailing_list_index(path: str) -> str:
20
- """Strip a trailing numeric segment from a parent_path.
21
-
22
- E.g., 'spec.containers.0' -> 'spec.containers'.
23
- Returns the path unchanged if no trailing numeric segment exists.
24
- """
25
  return _LIST_INDEX_RE.sub("", path)
26
 
27
 
28
  def compute_children_info(nodes: list[YamlNode]) -> dict:
29
- """Compute per-position parent/child relationships.
30
-
31
- For each KEY/LIST_KEY position, its children are KEY/LIST_KEY positions
32
- whose parent_path equals this key's full_path. (VALUE nodes are leaves —
33
- their hidden states are used in the aggregator without being treated as
34
- "children" of any KEY for subtree purposes.)
35
-
36
- For KEYs inside list items, parent_path ends in a numeric list index
37
- (e.g., 'spec.containers.0'), but the linearizer never emits a synthetic
38
- list-item node, so a direct lookup misses. We strip the trailing numeric
39
- segment and link to the list-key itself. This flattens all list items
40
- into their list parent — per-item grouping is lost, but the aggregator
41
- still produces a meaningful doc vector. Phase 1 may add synthetic
42
- list-item nodes if per-item grouping matters.
43
-
44
- Returns a dict with:
45
- children_of: list[list[int]] — children's positions per node
46
- parent_of: list[int] — parent position (or -1)
47
- key_positions: list[int] — positions that are KEY/LIST_KEY
48
- depth_of: list[int] — depth per position
49
- full_path_of: list[str] — full path per position
50
- """
51
  n = len(nodes)
52
  full_path_of: list[str] = []
53
  for node in nodes:
@@ -56,7 +31,6 @@ def compute_children_info(nodes: list[YamlNode]) -> dict:
56
  else:
57
  full_path_of.append(node.token)
58
 
59
- # Index KEY positions by their full_path
60
  key_positions: list[int] = [
61
  i for i, node in enumerate(nodes)
62
  if node.node_type in (NodeType.KEY, NodeType.LIST_KEY)
@@ -73,11 +47,8 @@ def compute_children_info(nodes: list[YamlNode]) -> dict:
73
  parent_path = nodes[p].parent_path
74
  if not parent_path:
75
  continue
76
- # Try direct lookup first
77
  parent_pos = path_to_key_pos.get(parent_path)
78
  if parent_pos is None:
79
- # Try with trailing list index stripped
80
- # (e.g., "spec.containers.0" -> "spec.containers")
81
  stripped = _strip_trailing_list_index(parent_path)
82
  if stripped != parent_path:
83
  parent_pos = path_to_key_pos.get(stripped)
@@ -104,7 +75,7 @@ _MASKABLE_TYPES = (NodeType.KEY, NodeType.LIST_KEY)
104
 
105
 
106
  class YamlBertDataset(Dataset):
107
- """YAML-BERT dataset: atomic labels at masked positions + children info per doc."""
108
 
109
  def __init__(
110
  self,
@@ -118,18 +89,12 @@ class YamlBertDataset(Dataset):
118
  self.max_seq_len = config.max_seq_len
119
  self.recon_enabled = config.recon_enabled
120
 
121
- # Precompute per-doc children_info AND descendant cache (subtree picker
122
- # uses descendants on every __getitem__ call; cache once at init).
123
  self._cached_children_info: list[dict] = []
124
  self._cached_descendants: list[dict[int, set[int]] | None] = []
125
- if self.recon_enabled and len(documents) > 100:
126
- import logging
127
- logging.getLogger(__name__).info(
128
- "YamlBertDataset: precomputing descendants for %d docs (recon enabled)",
129
- len(documents),
130
- )
131
  for doc in documents:
132
- ci = compute_children_info(doc[: self.max_seq_len])
 
 
133
  self._cached_children_info.append(ci)
134
  if self.recon_enabled:
135
  desc_cache: dict[int, set[int]] = {}
@@ -145,80 +110,116 @@ class YamlBertDataset(Dataset):
145
 
146
  def __getitem__(self, idx: int) -> dict:
147
  nodes = self.documents[idx]
148
- if len(nodes) > self.max_seq_len:
149
- nodes = nodes[: self.max_seq_len]
150
-
151
- token_ids: list[int] = []
152
- node_types: list[int] = []
153
- depths: list[int] = []
154
- sibling_indices: list[int] = []
155
 
156
- for node in nodes:
157
- if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
158
- token_ids.append(self.vocab.encode_key(node.token))
159
- else:
160
- token_ids.append(self.vocab.encode_value(node.token))
161
- node_types.append(_NODE_TYPE_INDEX[node.node_type])
162
- depths.append(min(node.depth, 15))
163
- sibling_indices.append(min(node.sibling_index, 31))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
- atomic_labels: list[int] = [-100] * len(nodes)
166
- mask_id: int = self.vocab.special_tokens["[MASK]"]
167
- unk_id: int = self.vocab.special_tokens["[UNK]"]
 
 
168
 
169
  mlm_masked_positions: set[int] = set()
170
- for i, node in enumerate(nodes):
171
- if node.node_type not in _MASKABLE_TYPES:
172
- continue
173
  if random.random() >= self.mask_prob:
174
  continue
175
- atomic_id = self.vocab.encode_atomic_target(node.token)
 
176
  if atomic_id == unk_id:
177
- continue # skip [UNK] targets (Lever 1)
178
- atomic_labels[i] = atomic_id
179
- mlm_masked_positions.add(i)
180
  r = random.random()
 
181
  if r < 0.8:
182
- token_ids[i] = mask_id
 
183
  elif r < 0.9:
184
- token_ids[i] = random.randint(
185
- len(self.vocab.special_tokens),
186
- len(self.vocab.key_vocab) + len(self.vocab.special_tokens) - 1,
187
- )
188
 
189
  result = {
190
- "token_ids": torch.tensor(token_ids, dtype=torch.long),
191
- "node_types": torch.tensor(node_types, dtype=torch.long),
192
- "depths": torch.tensor(depths, dtype=torch.long),
193
- "sibling_indices": torch.tensor(sibling_indices, dtype=torch.long),
 
194
  "atomic_labels": torch.tensor(atomic_labels, dtype=torch.long),
195
- "children_info": self._cached_children_info[idx],
 
196
  }
197
 
198
  if self.recon_enabled:
199
  from yaml_bert.subtree_masking import pick_subtrees, bag_of_keys_target
200
- ci = self._cached_children_info[idx]
201
  picked_roots = pick_subtrees(
202
- N=len(nodes),
203
- key_positions=ci["key_positions"],
204
- depth_of=ci["depth_of"],
205
- children_of=ci["children_of"],
206
  mlm_masked_positions=mlm_masked_positions,
207
  rng=random,
208
- descendants_cache=self._cached_descendants[idx],
 
 
 
 
209
  )
210
- # Build subtree_mask + apply [MASK] to subtree positions
211
- subtree_mask = torch.zeros(len(nodes), dtype=torch.bool)
212
  picked_positions_all: set[int] = set()
213
  bag_targets: list[torch.Tensor] = []
214
- # position → key string lookup for bag-of-keys building
215
  position_to_key_str = {
216
- i: nodes[i].token
217
- for i in range(len(nodes))
218
- if nodes[i].node_type in (NodeType.KEY, NodeType.LIST_KEY)
219
  }
220
  for root_pos in picked_roots:
221
- descs = self._cached_descendants[idx][root_pos]
 
 
 
222
  picked_positions_all |= descs
223
  bag_targets.append(bag_of_keys_target(
224
  subtree_positions=descs,
@@ -226,14 +227,16 @@ class YamlBertDataset(Dataset):
226
  atomic_vocab=self.vocab.atomic_target_vocab,
227
  vocab_size=self.vocab.atomic_target_vocab_size,
228
  ))
229
- for pos in picked_positions_all:
230
- subtree_mask[pos] = True
231
- token_ids[pos] = mask_id
232
- # Re-encode token_ids tensor since we mutated the list
233
- result["token_ids"] = torch.tensor(token_ids, dtype=torch.long)
 
 
234
  result["subtree_mask"] = subtree_mask
235
- result["subtree_roots"] = picked_roots # list[int]
236
- result["bag_of_keys_targets"] = bag_targets # list[Tensor (V,)]
237
  result["_atomic_vocab_size"] = self.vocab.atomic_target_vocab_size
238
 
239
  return result
@@ -243,63 +246,79 @@ _COLLATE_NON_TENSOR_KEYS = frozenset({
243
  "children_info",
244
  "subtree_roots",
245
  "bag_of_keys_targets",
246
- "subtree_mask", # bool tensor handled separately
247
- "_atomic_vocab_size", # int, used by collate to size empty fallback tensors
 
248
  })
249
 
250
 
251
  def collate_fn(batch: list[dict]) -> dict:
252
- """Pad tensor fields, keep children_info as a list."""
253
- max_len = max(item["token_ids"].size(0) for item in batch)
254
- padded: dict[str, list[torch.Tensor]] = {
255
- k: [] for k in batch[0].keys() if k not in _COLLATE_NON_TENSOR_KEYS
256
- }
 
 
 
 
 
 
 
 
257
  padding_masks: list[torch.Tensor] = []
258
  batch_info: list[dict] = []
 
259
 
260
  for item in batch:
261
- seq_len = item["token_ids"].size(0)
262
- pad_len = max_len - seq_len
263
- for key in padded:
264
- pad_value = -100 if key == "atomic_labels" else 0
265
- if pad_len > 0:
266
- padding = torch.full((pad_len,), pad_value, dtype=torch.long)
267
- padded[key].append(torch.cat([item[key], padding]))
268
  else:
269
- padded[key].append(item[key])
 
 
 
 
 
 
 
 
 
 
270
  mask = torch.cat([
271
- torch.zeros(seq_len, dtype=torch.bool),
272
- torch.ones(pad_len, dtype=torch.bool),
273
- ]) if pad_len > 0 else torch.zeros(seq_len, dtype=torch.bool)
274
  padding_masks.append(mask)
275
  batch_info.append(item["children_info"])
 
276
 
277
- result = {k: torch.stack(v) for k, v in padded.items()}
 
278
  result["padding_mask"] = torch.stack(padding_masks)
279
  result["batch_info"] = batch_info
 
280
 
281
- # Vectorized-aggregator precompute. CPU work, runs in DataLoader workers.
282
  B = len(batch)
283
- N = max_len
284
-
285
- # parent_of_tensor: (B, N) long. -1 sentinel for no-parent, non-key, or padding.
286
- parent_of_tensor = torch.full((B, N), -1, dtype=torch.long)
287
  for b_idx, info in enumerate(batch_info):
288
- parent_of = info["parent_of"] # list[int] of length n_b
289
  n_b = len(parent_of)
290
  if n_b > 0:
291
  parent_of_tensor[b_idx, :n_b] = torch.tensor(parent_of, dtype=torch.long)
292
-
293
- # top_level_key_mask: (B, N) bool. True where depth==0 AND position is a KEY.
294
- top_level_key_mask = torch.zeros((B, N), dtype=torch.bool)
295
- for b_idx, info in enumerate(batch_info):
296
  depth_of = info["depth_of"]
297
  depth_zero_kps = [kp for kp in info["key_positions"] if depth_of[kp] == 0]
298
  if depth_zero_kps:
299
  top_level_key_mask[b_idx, depth_zero_kps] = True
300
 
301
- # edges_by_depth: dict[depth, (E, 3) long] of [doc_idx, child_pos, parent_pos] across batch.
302
- # parents_by_depth: dict[depth, (P, 2) long] of unique [doc_idx, parent_pos] with at-least-one-child.
303
  edges_by_depth: dict[int, list[tuple[int, int, int]]] = {}
304
  parents_set_by_depth: dict[int, set[tuple[int, int]]] = {}
305
  for b_idx, info in enumerate(batch_info):
@@ -328,13 +347,11 @@ def collate_fn(batch: list[dict]) -> dict:
328
  for d, parents_set in parents_set_by_depth.items()
329
  }
330
 
331
- # Subtree-mask batching (only present when recon is enabled per-item).
332
  if "subtree_mask" in batch[0]:
333
- # subtree_mask: (B, N) bool, pad with False
334
  subtree_masks: list[torch.Tensor] = []
335
  for item in batch:
336
  sm = item["subtree_mask"]
337
- pad_len = max_len - sm.size(0)
338
  if pad_len > 0:
339
  subtree_masks.append(torch.cat([
340
  sm, torch.zeros(pad_len, dtype=torch.bool),
@@ -343,8 +360,6 @@ def collate_fn(batch: list[dict]) -> dict:
343
  subtree_masks.append(sm)
344
  result["subtree_mask"] = torch.stack(subtree_masks)
345
 
346
- # subtree_roots_flat: (M, 2) of [batch_idx, root_pos]; M = total roots
347
- # bag_of_keys_targets_flat: (M, V_atomic)
348
  flat_roots: list[tuple[int, int]] = []
349
  flat_targets: list[torch.Tensor] = []
350
  for b_idx, item in enumerate(batch):
@@ -359,12 +374,7 @@ def collate_fn(batch: list[dict]) -> dict:
359
  )
360
  result["bag_of_keys_targets_flat"] = torch.stack(flat_targets)
361
  else:
362
- # Even with recon enabled, all docs in this batch may have empty
363
- # picks (small docs, no candidates). Emit empty tensors so the
364
- # consumer can detect "no subtrees this batch."
365
- result["subtree_roots_flat"] = torch.zeros(
366
- (0, 2), dtype=torch.long,
367
- )
368
  v = batch[0].get("_atomic_vocab_size", 0)
369
  result["bag_of_keys_targets_flat"] = torch.zeros(
370
  (0, v), dtype=torch.float,
 
1
+ """v9 YAML-BERT dataset: BPE-expand each linearizer node into subword
2
+ positions, mask whole logical KEYs, emit per-logical-node atomic labels."""
3
  from __future__ import annotations
4
 
5
  import random
 
18
 
19
 
20
  def _strip_trailing_list_index(path: str) -> str:
 
 
 
 
 
21
  return _LIST_INDEX_RE.sub("", path)
22
 
23
 
24
  def compute_children_info(nodes: list[YamlNode]) -> dict:
25
+ """Same as v8 — operates on LOGICAL nodes (not subwords)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  n = len(nodes)
27
  full_path_of: list[str] = []
28
  for node in nodes:
 
31
  else:
32
  full_path_of.append(node.token)
33
 
 
34
  key_positions: list[int] = [
35
  i for i, node in enumerate(nodes)
36
  if node.node_type in (NodeType.KEY, NodeType.LIST_KEY)
 
47
  parent_path = nodes[p].parent_path
48
  if not parent_path:
49
  continue
 
50
  parent_pos = path_to_key_pos.get(parent_path)
51
  if parent_pos is None:
 
 
52
  stripped = _strip_trailing_list_index(parent_path)
53
  if stripped != parent_path:
54
  parent_pos = path_to_key_pos.get(stripped)
 
75
 
76
 
77
  class YamlBertDataset(Dataset):
78
+ """v9 dataset: subword expansion + whole-key MLM masking + recon."""
79
 
80
  def __init__(
81
  self,
 
89
  self.max_seq_len = config.max_seq_len
90
  self.recon_enabled = config.recon_enabled
91
 
 
 
92
  self._cached_children_info: list[dict] = []
93
  self._cached_descendants: list[dict[int, set[int]] | None] = []
 
 
 
 
 
 
94
  for doc in documents:
95
+ # Cap LOGICAL nodes here; BPE expansion may still exceed max_seq_len
96
+ # at the subword level (handled below by truncation).
97
+ ci = compute_children_info(doc)
98
  self._cached_children_info.append(ci)
99
  if self.recon_enabled:
100
  desc_cache: dict[int, set[int]] = {}
 
110
 
111
  def __getitem__(self, idx: int) -> dict:
112
  nodes = self.documents[idx]
 
 
 
 
 
 
 
113
 
114
+ # Pass 1: BPE-expand each logical node, building per-subword tensors.
115
+ sub_token_ids: list[int] = []
116
+ sub_node_types: list[int] = []
117
+ sub_depths: list[int] = []
118
+ sub_sibling: list[int] = []
119
+ sub_logical_ids: list[int] = []
120
+ per_logical_subword_spans: list[tuple[int, int]] = []
121
+ # We may need to drop trailing logical nodes if subword expansion
122
+ # blows the max_seq_len cap.
123
+ kept_logical: int = 0
124
+ for logical_idx, node in enumerate(nodes):
125
+ is_value = node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE)
126
+ ids = self.vocab.encode_token(node.token, is_value=is_value)
127
+ if len(sub_token_ids) + len(ids) > self.max_seq_len:
128
+ break
129
+ start = len(sub_token_ids)
130
+ sub_token_ids.extend(ids)
131
+ sub_node_types.extend([_NODE_TYPE_INDEX[node.node_type]] * len(ids))
132
+ sub_depths.extend([min(node.depth, 15)] * len(ids))
133
+ sub_sibling.extend([min(node.sibling_index, 31)] * len(ids))
134
+ sub_logical_ids.extend([kept_logical] * len(ids))
135
+ per_logical_subword_spans.append((start, start + len(ids)))
136
+ kept_logical += 1
137
+
138
+ n_logical = kept_logical
139
+ # Truncate cached children_info to kept logicals
140
+ ci = self._cached_children_info[idx]
141
+ kept_set = set(range(n_logical))
142
+ children_of_t = [
143
+ [c for c in ci["children_of"][p] if c in kept_set]
144
+ for p in range(n_logical)
145
+ ]
146
+ parent_of_t = [
147
+ ci["parent_of"][p] if (ci["parent_of"][p] in kept_set or ci["parent_of"][p] == -1) else -1
148
+ for p in range(n_logical)
149
+ ]
150
+ key_positions_t = [p for p in ci["key_positions"] if p < n_logical]
151
+ depth_of_t = ci["depth_of"][:n_logical]
152
+ full_path_of_t = ci["full_path_of"][:n_logical]
153
+ ci_t = {
154
+ "children_of": children_of_t,
155
+ "parent_of": parent_of_t,
156
+ "key_positions": key_positions_t,
157
+ "depth_of": depth_of_t,
158
+ "full_path_of": full_path_of_t,
159
+ }
160
 
161
+ # Pass 2: whole-key MLM masking, one decision per LOGICAL KEY.
162
+ atomic_labels: list[int] = [-100] * n_logical
163
+ mask_id = self.vocab.mask_id
164
+ unk_id = self.vocab.unk_id
165
+ subword_vocab_size = self.vocab.subword_vocab_size
166
 
167
  mlm_masked_positions: set[int] = set()
168
+ for logical_idx in key_positions_t:
 
 
169
  if random.random() >= self.mask_prob:
170
  continue
171
+ tok = nodes[logical_idx].token
172
+ atomic_id = self.vocab.encode_atomic_target(tok)
173
  if atomic_id == unk_id:
174
+ continue # skip [UNK] targets (Lever 1, carried from v8)
175
+ atomic_labels[logical_idx] = atomic_id
176
+ mlm_masked_positions.add(logical_idx)
177
  r = random.random()
178
+ start, end = per_logical_subword_spans[logical_idx]
179
  if r < 0.8:
180
+ for p in range(start, end):
181
+ sub_token_ids[p] = mask_id
182
  elif r < 0.9:
183
+ for p in range(start, end):
184
+ sub_token_ids[p] = random.randint(4, subword_vocab_size - 1)
 
 
185
 
186
  result = {
187
+ "token_ids": torch.tensor(sub_token_ids, dtype=torch.long),
188
+ "node_types": torch.tensor(sub_node_types, dtype=torch.long),
189
+ "depths": torch.tensor(sub_depths, dtype=torch.long),
190
+ "sibling_indices": torch.tensor(sub_sibling, dtype=torch.long),
191
+ "logical_ids": torch.tensor(sub_logical_ids, dtype=torch.long),
192
  "atomic_labels": torch.tensor(atomic_labels, dtype=torch.long),
193
+ "children_info": ci_t,
194
+ "n_logical": n_logical,
195
  }
196
 
197
  if self.recon_enabled:
198
  from yaml_bert.subtree_masking import pick_subtrees, bag_of_keys_target
 
199
  picked_roots = pick_subtrees(
200
+ N=n_logical,
201
+ key_positions=key_positions_t,
202
+ depth_of=depth_of_t,
203
+ children_of=children_of_t,
204
  mlm_masked_positions=mlm_masked_positions,
205
  rng=random,
206
+ descendants_cache={
207
+ kp: descendants_of(kp, children_of_t)
208
+ for kp in key_positions_t
209
+ if children_of_t[kp]
210
+ },
211
  )
212
+ subtree_mask = torch.zeros(n_logical, dtype=torch.bool)
 
213
  picked_positions_all: set[int] = set()
214
  bag_targets: list[torch.Tensor] = []
 
215
  position_to_key_str = {
216
+ i: nodes[i].token for i in key_positions_t
 
 
217
  }
218
  for root_pos in picked_roots:
219
+ descs = {
220
+ d for d in descendants_of(root_pos, children_of_t)
221
+ if d < n_logical
222
+ }
223
  picked_positions_all |= descs
224
  bag_targets.append(bag_of_keys_target(
225
  subtree_positions=descs,
 
227
  atomic_vocab=self.vocab.atomic_target_vocab,
228
  vocab_size=self.vocab.atomic_target_vocab_size,
229
  ))
230
+ # Apply [MASK] to ALL subwords of each logical position in the picked subtree
231
+ for lpos in picked_positions_all:
232
+ subtree_mask[lpos] = True
233
+ start, end = per_logical_subword_spans[lpos]
234
+ for p in range(start, end):
235
+ sub_token_ids[p] = mask_id
236
+ result["token_ids"] = torch.tensor(sub_token_ids, dtype=torch.long)
237
  result["subtree_mask"] = subtree_mask
238
+ result["subtree_roots"] = picked_roots
239
+ result["bag_of_keys_targets"] = bag_targets
240
  result["_atomic_vocab_size"] = self.vocab.atomic_target_vocab_size
241
 
242
  return result
 
246
  "children_info",
247
  "subtree_roots",
248
  "bag_of_keys_targets",
249
+ "subtree_mask",
250
+ "_atomic_vocab_size",
251
+ "n_logical",
252
  })
253
 
254
 
255
  def collate_fn(batch: list[dict]) -> dict:
256
+ """Pad subword-level tensors AND logical-level tensors.
257
+
258
+ Subword-level (per-position): token_ids, node_types, depths, sibling_indices,
259
+ logical_ids — padded to max subword length
260
+ Logical-level (per-logical-node): atomic_labels — padded to max logical count
261
+ """
262
+ max_sub_len = max(item["token_ids"].size(0) for item in batch)
263
+ max_logical = max(item["n_logical"] for item in batch)
264
+
265
+ subword_keys = ("token_ids", "node_types", "depths", "sibling_indices",
266
+ "logical_ids")
267
+ padded_sub: dict[str, list[torch.Tensor]] = {k: [] for k in subword_keys}
268
+ padded_labels: list[torch.Tensor] = []
269
  padding_masks: list[torch.Tensor] = []
270
  batch_info: list[dict] = []
271
+ n_logical_per_doc: list[int] = []
272
 
273
  for item in batch:
274
+ sub_len = item["token_ids"].size(0)
275
+ pad_sub = max_sub_len - sub_len
276
+ for k in subword_keys:
277
+ pad_value = -1 if k == "logical_ids" else 0
278
+ if pad_sub > 0:
279
+ padding = torch.full((pad_sub,), pad_value, dtype=torch.long)
280
+ padded_sub[k].append(torch.cat([item[k], padding]))
281
  else:
282
+ padded_sub[k].append(item[k])
283
+
284
+ labels = item["atomic_labels"]
285
+ pad_lab = max_logical - labels.size(0)
286
+ if pad_lab > 0:
287
+ padded_labels.append(torch.cat([
288
+ labels, torch.full((pad_lab,), -100, dtype=torch.long),
289
+ ]))
290
+ else:
291
+ padded_labels.append(labels)
292
+
293
  mask = torch.cat([
294
+ torch.zeros(sub_len, dtype=torch.bool),
295
+ torch.ones(pad_sub, dtype=torch.bool),
296
+ ]) if pad_sub > 0 else torch.zeros(sub_len, dtype=torch.bool)
297
  padding_masks.append(mask)
298
  batch_info.append(item["children_info"])
299
+ n_logical_per_doc.append(item["n_logical"])
300
 
301
+ result = {k: torch.stack(v) for k, v in padded_sub.items()}
302
+ result["atomic_labels"] = torch.stack(padded_labels)
303
  result["padding_mask"] = torch.stack(padding_masks)
304
  result["batch_info"] = batch_info
305
+ result["n_logical_per_doc"] = torch.tensor(n_logical_per_doc, dtype=torch.long)
306
 
307
+ # parent_of_tensor and top_level_key_mask now operate at LOGICAL level
308
  B = len(batch)
309
+ L = max_logical
310
+ parent_of_tensor = torch.full((B, L), -1, dtype=torch.long)
311
+ top_level_key_mask = torch.zeros((B, L), dtype=torch.bool)
 
312
  for b_idx, info in enumerate(batch_info):
313
+ parent_of = info["parent_of"]
314
  n_b = len(parent_of)
315
  if n_b > 0:
316
  parent_of_tensor[b_idx, :n_b] = torch.tensor(parent_of, dtype=torch.long)
 
 
 
 
317
  depth_of = info["depth_of"]
318
  depth_zero_kps = [kp for kp in info["key_positions"] if depth_of[kp] == 0]
319
  if depth_zero_kps:
320
  top_level_key_mask[b_idx, depth_zero_kps] = True
321
 
 
 
322
  edges_by_depth: dict[int, list[tuple[int, int, int]]] = {}
323
  parents_set_by_depth: dict[int, set[tuple[int, int]]] = {}
324
  for b_idx, info in enumerate(batch_info):
 
347
  for d, parents_set in parents_set_by_depth.items()
348
  }
349
 
 
350
  if "subtree_mask" in batch[0]:
 
351
  subtree_masks: list[torch.Tensor] = []
352
  for item in batch:
353
  sm = item["subtree_mask"]
354
+ pad_len = max_logical - sm.size(0)
355
  if pad_len > 0:
356
  subtree_masks.append(torch.cat([
357
  sm, torch.zeros(pad_len, dtype=torch.bool),
 
360
  subtree_masks.append(sm)
361
  result["subtree_mask"] = torch.stack(subtree_masks)
362
 
 
 
363
  flat_roots: list[tuple[int, int]] = []
364
  flat_targets: list[torch.Tensor] = []
365
  for b_idx, item in enumerate(batch):
 
374
  )
375
  result["bag_of_keys_targets_flat"] = torch.stack(flat_targets)
376
  else:
377
+ result["subtree_roots_flat"] = torch.zeros((0, 2), dtype=torch.long)
 
 
 
 
 
378
  v = batch[0].get("_atomic_vocab_size", 0)
379
  result["bag_of_keys_targets_flat"] = torch.zeros(
380
  (0, v), dtype=torch.float,
yaml_bert/embedding.py CHANGED
@@ -7,28 +7,25 @@ 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)
@@ -54,14 +51,9 @@ class YamlBertEmbedding(nn.Module):
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:
@@ -69,7 +61,7 @@ class YamlBertEmbedding(nn.Module):
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)
 
7
 
8
 
9
  class YamlBertEmbedding(nn.Module):
10
+ """v9 embedding layer: single subword table + tree positional encoding.
11
 
12
  Produces input vectors by summing:
13
+ - Subword embedding (looked up by token_id; same table for KEY and VALUE
14
+ positions — what they ARE is signalled separately via node_type_emb)
15
  - Tree positional encoding (composition depends on config.tree_pos_variant)
 
 
16
  """
17
 
18
  def __init__(
19
  self,
20
  config: YamlBertConfig,
21
+ subword_vocab_size: int,
 
22
  ) -> None:
23
  super().__init__()
24
  d: int = config.d_model
25
  variant: TreePosVariant = config.tree_pos_variant
26
  self.variant: TreePosVariant = variant
27
 
28
+ self.subword_embedding: nn.Embedding = nn.Embedding(subword_vocab_size, d)
 
29
  self.node_type_embedding: nn.Embedding = nn.Embedding(4, d)
30
 
31
  use_depth: bool = variant in (TreePosVariant.FULL, TreePosVariant.NO_SIBLING)
 
51
  depths: torch.Tensor,
52
  sibling_indices: torch.Tensor,
53
  ) -> torch.Tensor:
54
+ token_emb = self.subword_embedding(token_ids)
 
 
 
 
 
55
 
56
+ tree_pos = self.node_type_embedding(node_types)
57
  if self.depth_embedding is not None:
58
  tree_pos = tree_pos + self.depth_embedding(depths)
59
  if self.sibling_embedding is not None:
 
61
  if self.pos_embedding is not None:
62
  seq_len: int = token_ids.size(1)
63
  max_pos: int = self.pos_embedding.num_embeddings
64
+ positions = (
65
  torch.arange(seq_len, device=token_ids.device)
66
  .clamp(max=max_pos - 1)
67
  .unsqueeze(0)
yaml_bert/model.py CHANGED
@@ -84,6 +84,8 @@ class YamlBertModel(nn.Module):
84
  batch_info: list[dict],
85
  padding_mask: torch.Tensor | None = None,
86
  *,
 
 
87
  parent_of_tensor: torch.Tensor | None = None,
88
  top_level_key_mask: torch.Tensor | None = None,
89
  edges_by_depth: dict[int, torch.Tensor] | None = None,
@@ -93,14 +95,21 @@ class YamlBertModel(nn.Module):
93
  ) -> tuple:
94
  """Returns (logits, doc_vec) or (logits, doc_vec, recon_logits).
95
 
96
- recon_logits only returned when subtree_roots_flat is provided AND
97
- has at least one row."""
 
 
 
 
 
98
  x = self.embedding(token_ids, node_types, depths, sibling_indices)
99
  x = self.encoder(x, src_key_padding_mask=padding_mask)
100
 
101
- # Aggregator: forwards through to its own vectorized/reference dispatch.
102
  subtree_vecs, doc_vec = self.aggregator(
103
  x, batch_info,
 
 
104
  parent_of_tensor=parent_of_tensor,
105
  top_level_key_mask=top_level_key_mask,
106
  edges_by_depth=edges_by_depth,
@@ -108,51 +117,60 @@ class YamlBertModel(nn.Module):
108
  subtree_mask=subtree_mask,
109
  )
110
 
111
- b, n, d = x.shape
 
 
 
112
 
113
  if parent_of_tensor is not None:
114
- # Vectorized s_parent. parent_of_tensor being set implies all four
115
- # precompute kwargs were provided (aggregator enforces all-or-none).
116
- safe_parent = parent_of_tensor.clamp(min=0) # (B, N)
117
  s_parent = torch.gather(
118
  subtree_vecs, dim=1,
119
  index=safe_parent.unsqueeze(-1).expand(-1, -1, d),
120
- ) # (B, N, d)
121
- no_parent_mask = (parent_of_tensor == -1).unsqueeze(-1) # (B, N, 1)
122
  s_parent = torch.where(
123
  no_parent_mask, doc_vec.unsqueeze(1), s_parent,
124
  )
125
  else:
126
- # Reference path: per-doc Python loop (kept for tests / fallback).
127
- s_parent = torch.zeros_like(x)
128
  for doc_idx in range(b):
129
  parent_of = batch_info[doc_idx]["parent_of"]
130
- for i in range(min(n, len(parent_of))):
131
  p = parent_of[i]
132
  if p >= 0:
133
  s_parent[doc_idx, i] = subtree_vecs[doc_idx, p]
134
  else:
135
  s_parent[doc_idx, i] = doc_vec[doc_idx]
136
 
137
- doc_vec_broadcast = doc_vec.unsqueeze(1).expand(b, n, d)
138
- head_input = torch.cat([x, doc_vec_broadcast, s_parent], dim=-1)
139
- logits = self.token_head(head_input)
140
 
141
- # Reconstruction path: only if caller provided subtree roots
142
  if subtree_roots_flat is not None and subtree_roots_flat.size(0) > 0:
143
- # subtree_roots_flat: (M, 2) of [batch_idx, root_pos]
144
- batch_idx_per_root = subtree_roots_flat[:, 0] # (M,)
145
- root_pos_per_root = subtree_roots_flat[:, 1] # (M,)
146
-
147
- doc_vec_per_root = doc_vec[batch_idx_per_root] # (M, d_model)
148
-
149
- # Build pos_emb_per_root from the same depth/sibling embedding params
150
- # already used in the embedding layer — no new parameters introduced.
151
- root_depths = depths[batch_idx_per_root, root_pos_per_root] # (M,)
152
- root_siblings = sibling_indices[batch_idx_per_root, root_pos_per_root] # (M,)
153
- depth_e = self.embedding.depth_embedding(root_depths) # (M, d_model)
154
- sibling_e = self.embedding.sibling_embedding(root_siblings) # (M, d_model)
155
- pos_emb_per_root = torch.cat([depth_e, sibling_e], dim=-1) # (M, 2*d_model)
 
 
 
 
 
 
 
 
 
 
156
 
157
  recon_logits = self.recon_head(doc_vec_per_root, pos_emb_per_root)
158
  return logits, doc_vec, recon_logits
 
84
  batch_info: list[dict],
85
  padding_mask: torch.Tensor | None = None,
86
  *,
87
+ logical_ids: torch.Tensor,
88
+ n_logical_per_doc: torch.Tensor,
89
  parent_of_tensor: torch.Tensor | None = None,
90
  top_level_key_mask: torch.Tensor | None = None,
91
  edges_by_depth: dict[int, torch.Tensor] | None = None,
 
95
  ) -> tuple:
96
  """Returns (logits, doc_vec) or (logits, doc_vec, recon_logits).
97
 
98
+ v9: token_ids/node_types/depths/sibling_indices/logical_ids/padding_mask
99
+ are SUBWORD-level (B, N_sub). atomic_labels and the aggregator output
100
+ are LOGICAL-level (B, L_max). The Token Head consumes per-logical-node
101
+ pooled hidden state.
102
+ """
103
+ from yaml_bert.aggregator import _pool_subwords
104
+
105
  x = self.embedding(token_ids, node_types, depths, sibling_indices)
106
  x = self.encoder(x, src_key_padding_mask=padding_mask)
107
 
108
+ # Aggregator pools internally; (subtree_vecs, doc_vec) are logical-level.
109
  subtree_vecs, doc_vec = self.aggregator(
110
  x, batch_info,
111
+ logical_ids=logical_ids,
112
+ n_logical_per_doc=n_logical_per_doc,
113
  parent_of_tensor=parent_of_tensor,
114
  top_level_key_mask=top_level_key_mask,
115
  edges_by_depth=edges_by_depth,
 
117
  subtree_mask=subtree_mask,
118
  )
119
 
120
+ # Re-pool subword hiddens to logical level for the Token Head input.
121
+ # (One extra index_add call; the plan flags this as a follow-up.)
122
+ h_logical = _pool_subwords(x, logical_ids, n_logical_per_doc)
123
+ b, L_max, d = h_logical.shape
124
 
125
  if parent_of_tensor is not None:
126
+ safe_parent = parent_of_tensor.clamp(min=0)
 
 
127
  s_parent = torch.gather(
128
  subtree_vecs, dim=1,
129
  index=safe_parent.unsqueeze(-1).expand(-1, -1, d),
130
+ )
131
+ no_parent_mask = (parent_of_tensor == -1).unsqueeze(-1)
132
  s_parent = torch.where(
133
  no_parent_mask, doc_vec.unsqueeze(1), s_parent,
134
  )
135
  else:
136
+ s_parent = torch.zeros_like(h_logical)
 
137
  for doc_idx in range(b):
138
  parent_of = batch_info[doc_idx]["parent_of"]
139
+ for i in range(min(L_max, len(parent_of))):
140
  p = parent_of[i]
141
  if p >= 0:
142
  s_parent[doc_idx, i] = subtree_vecs[doc_idx, p]
143
  else:
144
  s_parent[doc_idx, i] = doc_vec[doc_idx]
145
 
146
+ doc_vec_broadcast = doc_vec.unsqueeze(1).expand(b, L_max, d)
147
+ head_input = torch.cat([h_logical, doc_vec_broadcast, s_parent], dim=-1)
148
+ logits = self.token_head(head_input) # (B, L_max, atomic_vocab_size)
149
 
 
150
  if subtree_roots_flat is not None and subtree_roots_flat.size(0) > 0:
151
+ batch_idx_per_root = subtree_roots_flat[:, 0]
152
+ root_pos_per_root = subtree_roots_flat[:, 1]
153
+ doc_vec_per_root = doc_vec[batch_idx_per_root]
154
+ # Root positions live in LOGICAL coords. Look up depth from batch_info;
155
+ # sibling: find any subword with logical_id == root_pos, use its sibling.
156
+ root_depths_list = [
157
+ batch_info[bi]["depth_of"][rp]
158
+ for bi, rp in zip(batch_idx_per_root.tolist(), root_pos_per_root.tolist())
159
+ ]
160
+ root_siblings_list = []
161
+ for bi, rp in zip(batch_idx_per_root.tolist(), root_pos_per_root.tolist()):
162
+ positions = (logical_ids[bi] == rp).nonzero(as_tuple=True)[0]
163
+ if len(positions) > 0:
164
+ root_siblings_list.append(int(sibling_indices[bi, positions[0]].item()))
165
+ else:
166
+ # Shouldn't happen: a recon root must have at least one subword.
167
+ root_siblings_list.append(0)
168
+ root_depths = torch.tensor(root_depths_list, device=depths.device, dtype=torch.long)
169
+ root_siblings = torch.tensor(root_siblings_list, device=depths.device, dtype=torch.long)
170
+
171
+ depth_e = self.embedding.depth_embedding(root_depths)
172
+ sibling_e = self.embedding.sibling_embedding(root_siblings)
173
+ pos_emb_per_root = torch.cat([depth_e, sibling_e], dim=-1)
174
 
175
  recon_logits = self.recon_head(doc_vec_per_root, pos_emb_per_root)
176
  return logits, doc_vec, recon_logits
yaml_bert/suggest.py CHANGED
@@ -118,11 +118,14 @@ def suggest_missing_fields(
118
  return [], {}
119
  annotator.annotate(nodes)
120
 
121
- mask_id: int = vocab.special_tokens["[MASK]"]
122
 
123
  # Build atomic reverse map for decoding
124
  id_to_atomic: dict[int, str] = {v: k for k, v in vocab.atomic_target_vocab.items()}
125
- id_to_special: dict[int, str] = {v: k for k, v in vocab.special_tokens.items()}
 
 
 
126
 
127
  # Group key nodes by parent_path
128
  keys_by_parent: dict[str, set[str]] = {}
@@ -301,9 +304,12 @@ def _run_probe_v8(
301
  ds = YamlBertDataset([fake_nodes], vocab, infer_config)
302
  item = ds[0]
303
 
304
- # Apply [MASK] at insert_pos (dataset has mask_prob=0.0, so token is intact)
 
305
  item["token_ids"] = item["token_ids"].clone()
306
- item["token_ids"][insert_pos] = mask_id
 
 
307
 
308
  batch = collate_fn([item])
309
 
@@ -315,14 +321,18 @@ def _run_probe_v8(
315
  sibling_indices=batch["sibling_indices"],
316
  batch_info=batch["batch_info"],
317
  padding_mask=batch["padding_mask"],
 
 
318
  parent_of_tensor=batch["parent_of_tensor"],
319
  top_level_key_mask=batch["top_level_key_mask"],
320
  edges_by_depth=batch["edges_by_depth"],
321
  parents_by_depth=batch["parents_by_depth"],
322
  )
323
 
324
- # YamlBertModel returns (logits, doc_vec) or (logits, doc_vec, recon_logits)
325
- logits = out[0] # (1, N, V_atomic)
 
 
326
  probs = F.softmax(logits[0, insert_pos], dim=-1)
327
  topk = probs.topk(top_k + 5)
328
 
 
118
  return [], {}
119
  annotator.annotate(nodes)
120
 
121
+ mask_id: int = vocab.mask_id
122
 
123
  # Build atomic reverse map for decoding
124
  id_to_atomic: dict[int, str] = {v: k for k, v in vocab.atomic_target_vocab.items()}
125
+ id_to_special: dict[int, str] = {
126
+ vocab.pad_id: "[PAD]", vocab.unk_id: "[UNK]",
127
+ vocab.mask_id: "[MASK]", vocab.long_value_id: "[LONG_VALUE]",
128
+ }
129
 
130
  # Group key nodes by parent_path
131
  keys_by_parent: dict[str, set[str]] = {}
 
304
  ds = YamlBertDataset([fake_nodes], vocab, infer_config)
305
  item = ds[0]
306
 
307
+ # v9 whole-word masking: insert_pos is a LOGICAL position (index into
308
+ # fake_nodes). Mask ALL subword positions whose logical_id == insert_pos.
309
  item["token_ids"] = item["token_ids"].clone()
310
+ sub_positions = (item["logical_ids"] == insert_pos).nonzero(as_tuple=True)[0]
311
+ for p in sub_positions:
312
+ item["token_ids"][p] = mask_id
313
 
314
  batch = collate_fn([item])
315
 
 
321
  sibling_indices=batch["sibling_indices"],
322
  batch_info=batch["batch_info"],
323
  padding_mask=batch["padding_mask"],
324
+ logical_ids=batch["logical_ids"],
325
+ n_logical_per_doc=batch["n_logical_per_doc"],
326
  parent_of_tensor=batch["parent_of_tensor"],
327
  top_level_key_mask=batch["top_level_key_mask"],
328
  edges_by_depth=batch["edges_by_depth"],
329
  parents_by_depth=batch["parents_by_depth"],
330
  )
331
 
332
+ # v9 YamlBertModel returns (logits, doc_vec) where logits is
333
+ # (B, L_max, V_atomic) — indexed by LOGICAL position. insert_pos is
334
+ # already a logical index.
335
+ logits = out[0]
336
  probs = F.softmax(logits[0, insert_pos], dim=-1)
337
  topk = probs.topk(top_k + 5)
338
 
yaml_bert/tokenizer.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Subword tokenizer wrapper for YAML-BERT v9.
2
+
3
+ Wraps the HF `tokenizers` library so the rest of yaml_bert depends on
4
+ this stable interface, not on HF internals.
5
+
6
+ Vocabulary semantics:
7
+ - Special tokens reserved at training time: [PAD], [UNK], [MASK], [LONG_VALUE]
8
+ - Otherwise byte-level BPE; any string can be encoded.
9
+
10
+ Long-value rule (values only; keys never get this treatment):
11
+ - value length >= LONG_VALUE_THRESHOLD chars → single [LONG_VALUE] token
12
+ - MAX_CHARS_VALUE < value length < LONG_VALUE_THRESHOLD → truncate to MAX_CHARS_VALUE chars, then BPE
13
+ - shorter → BPE in full
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from tokenizers import Tokenizer
18
+
19
+ PAD_TOKEN = "[PAD]"
20
+ UNK_TOKEN = "[UNK]"
21
+ MASK_TOKEN = "[MASK]"
22
+ LONG_VALUE_TOKEN = "[LONG_VALUE]"
23
+
24
+ MAX_CHARS_VALUE = 64
25
+ LONG_VALUE_THRESHOLD = 256
26
+
27
+
28
+ class SubwordTokenizer:
29
+ """Wraps an HF Tokenizer with YAML-BERT-specific value-length rules."""
30
+
31
+ def __init__(self, hf_tokenizer: Tokenizer) -> None:
32
+ self._tok = hf_tokenizer
33
+ self.pad_id = hf_tokenizer.token_to_id(PAD_TOKEN)
34
+ self.unk_id = hf_tokenizer.token_to_id(UNK_TOKEN)
35
+ self.mask_id = hf_tokenizer.token_to_id(MASK_TOKEN)
36
+ self.long_value_id = hf_tokenizer.token_to_id(LONG_VALUE_TOKEN)
37
+ for name, val in (
38
+ (PAD_TOKEN, self.pad_id), (UNK_TOKEN, self.unk_id),
39
+ (MASK_TOKEN, self.mask_id), (LONG_VALUE_TOKEN, self.long_value_id),
40
+ ):
41
+ if val is None:
42
+ raise ValueError(
43
+ f"SubwordTokenizer: required special token {name!r} not "
44
+ f"in tokenizer vocab — was the tokenizer trained with "
45
+ f"this special token reserved?"
46
+ )
47
+
48
+ @classmethod
49
+ def load(cls, path: str) -> "SubwordTokenizer":
50
+ return cls(Tokenizer.from_file(path))
51
+
52
+ @property
53
+ def vocab_size(self) -> int:
54
+ return self._tok.get_vocab_size()
55
+
56
+ def encode_token(self, token: str, *, is_value: bool) -> list[int]:
57
+ """Encode one linearizer-node token to a list of subword ids.
58
+
59
+ See module docstring for the value-length rule. Keys are always
60
+ BPE-encoded in full regardless of length.
61
+ """
62
+ if is_value:
63
+ if len(token) >= LONG_VALUE_THRESHOLD:
64
+ return [self.long_value_id]
65
+ if len(token) > MAX_CHARS_VALUE:
66
+ token = token[:MAX_CHARS_VALUE]
67
+ return self._tok.encode(token).ids
68
+
69
+ def decode(self, ids: list[int]) -> str:
70
+ return self._tok.decode(ids)
yaml_bert/vocab.py CHANGED
@@ -1,308 +1,130 @@
 
 
 
 
 
 
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
  # Canonical casing for known Kubernetes kinds.
10
- # Maps lowercase → canonical form. Populated by VocabBuilder.build().
11
  _KIND_CANONICAL: dict[str, str] = {}
12
 
13
 
14
  def normalize_kind(kind: str) -> str:
15
- """Normalize kind value to canonical casing.
16
-
17
- 'configmap' → 'ConfigMap', 'pod' → 'Pod', etc.
18
- Unknown kinds are returned as-is.
19
- """
20
  return _KIND_CANONICAL.get(kind.lower(), kind)
21
 
22
 
23
  class Vocabulary:
 
 
 
 
 
 
 
24
  def __init__(
25
  self,
26
- key_vocab: dict[str, int],
27
- value_vocab: dict[str, int],
28
- special_tokens: dict[str, int],
29
- atomic_target_vocab: dict[str, int] | None = None,
30
  ) -> None:
31
- self.key_vocab = key_vocab
32
- self.value_vocab = value_vocab
33
- self.special_tokens = special_tokens
34
- self.atomic_target_vocab = atomic_target_vocab or {}
35
- self._id_to_key = {v: k for k, v in key_vocab.items()}
36
- self._id_to_value = {v: k for k, v in value_vocab.items()}
37
- self._id_to_special = {v: k for k, v in special_tokens.items()}
 
38
 
39
- def encode_key(self, token: str) -> int:
40
- return self.key_vocab.get(token, self.special_tokens["[UNK]"])
41
-
42
- def encode_value(self, token: str) -> int:
43
- return self.value_vocab.get(token, self.special_tokens["[UNK]"])
44
-
45
- def decode_key(self, id: int) -> str:
46
- if id in self._id_to_special:
47
- return self._id_to_special[id]
48
- return self._id_to_key.get(id, "[UNK]")
49
-
50
- def decode_value(self, id: int) -> str:
51
- if id in self._id_to_special:
52
- return self._id_to_special[id]
53
- return self._id_to_value.get(id, "[UNK]")
54
-
55
- def encode_atomic_target(self, target: str) -> int:
56
- """Encode a single key token as its atomic target id.
57
 
58
- Returns the [UNK] id if `target` is not in the atomic target vocab.
59
- The Token Head predicts single keys drawn from the same token universe
60
- as `key_vocab`.
61
- """
62
- return self.atomic_target_vocab.get(target, self.special_tokens["[UNK]"])
63
 
64
- @property
65
- def key_vocab_size(self) -> int:
66
- return len(self.key_vocab) + len(self.special_tokens)
67
 
68
  @property
69
- def value_vocab_size(self) -> int:
70
- return len(self.value_vocab) + len(self.special_tokens)
71
 
72
  @property
73
  def atomic_target_vocab_size(self) -> int:
74
- return len(self.atomic_target_vocab) + len(self.special_tokens)
 
75
 
76
  def save(self, path: str) -> None:
77
- data = {
78
- "key_vocab": self.key_vocab,
79
- "value_vocab": self.value_vocab,
80
- "special_tokens": self.special_tokens,
81
- "atomic_target_vocab": self.atomic_target_vocab,
82
- }
83
  with open(path, "w") as f:
84
- json.dump(data, f, indent=2)
 
 
 
85
 
86
  @classmethod
87
- def load(cls, path: str) -> Vocabulary:
88
  with open(path) as f:
89
  data = json.load(f)
90
- return cls(
91
- key_vocab=data["key_vocab"],
92
- value_vocab=data["value_vocab"],
93
- special_tokens=data["special_tokens"],
94
- atomic_target_vocab=data.get("atomic_target_vocab", {}),
95
  )
96
 
97
 
98
  class VocabBuilder:
99
- def build(
100
- self,
101
- nodes: list[YamlNode],
102
- min_freq: int = 1,
103
- *,
104
- key_min_freq: int | None = None,
105
- value_min_freq: int | None = None,
106
- ) -> Vocabulary:
107
- """Build vocab with optional per-category min_freq thresholds.
108
-
109
- Per-category thresholds default to the global `min_freq` for backward
110
- compatibility.
111
- """
112
- key_min_freq = key_min_freq if key_min_freq is not None else min_freq
113
- value_min_freq = value_min_freq if value_min_freq is not None else min_freq
114
- key_counts: dict[str, int] = {}
115
- value_counts: dict[str, int] = {}
116
- kind_set: set[str] = set()
117
-
118
- # First pass: collect all kind values to build canonical casing map.
119
- # The casing map (_KIND_CANONICAL) is needed by normalize_kind() which
120
- # is used downstream by _extract_kind in the types module.
121
- raw_kinds: dict[str, dict[str, int]] = {} # lowercase → {variant: count}
122
- prev_was_kind_key = False
123
- for node in nodes:
124
- if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
125
- prev_was_kind_key = (node.token == "kind" and node.depth == 0)
126
- elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
127
- if prev_was_kind_key:
128
- lower = node.token.lower()
129
- raw_kinds.setdefault(lower, {})
130
- raw_kinds[lower][node.token] = raw_kinds[lower].get(node.token, 0) + 1
131
- prev_was_kind_key = False
132
-
133
- # Build canonical map: most frequent casing wins
134
- _KIND_CANONICAL.clear()
135
- for lower, variants in raw_kinds.items():
136
- canonical = max(variants, key=variants.get)
137
- _KIND_CANONICAL[lower] = canonical
138
-
139
- # Second pass: count tokens with normalized kinds
140
- prev_was_kind_key = False
141
- for node in nodes:
142
- if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
143
- key_counts[node.token] = key_counts.get(node.token, 0) + 1
144
- prev_was_kind_key = (node.token == "kind" and node.depth == 0)
145
- elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
146
- value_counts[node.token] = value_counts.get(node.token, 0) + 1
147
- if prev_was_kind_key:
148
- kind_set.add(normalize_kind(node.token))
149
- prev_was_kind_key = False
150
-
151
- # Atomic target vocab: single key tokens. Uses the same filter
152
- # threshold as keys since the entries ARE keys.
153
- atomic_target_set: set[str] = {
154
- t for t, c in key_counts.items() if c >= key_min_freq
155
- }
156
-
157
- return self.build_from_counts(
158
- key_counts, value_counts, key_min_freq, kind_set,
159
- value_min_freq=value_min_freq,
160
- atomic_target_set=atomic_target_set,
161
- )
162
 
163
- @staticmethod
164
- def build_from_counts(
165
- key_counts: dict[str, int],
166
- value_counts: dict[str, int],
167
- min_freq: int = 1,
168
- kind_set: set[str] | None = None,
169
- *,
170
- value_min_freq: int | None = None,
171
- atomic_target_set: set[str] | None = None,
172
- ) -> Vocabulary:
173
- """Build vocabulary from pre-computed token counts.
174
-
175
- `min_freq` filters keys (and values if value_min_freq is None).
176
- `value_min_freq` overrides for values only.
177
- """
178
- if value_min_freq is None:
179
- value_min_freq = min_freq
180
- # Derive atomic_target_set from key_counts when not supplied. The atomic
181
- # set IS-A subset of keys by definition, so this is self-consistent and
182
- # prevents callers (e.g. build_from_huggingface) from silently producing
183
- # an empty atomic_target_vocab — which would break training (output head
184
- # would collapse to ~3 classes and every target would map to [UNK]).
185
- if atomic_target_set is None:
186
- atomic_target_set = {t for t, c in key_counts.items() if c >= min_freq}
187
- special_tokens = {tok: i for i, tok in enumerate(SPECIAL_TOKENS)}
188
- offset = len(special_tokens)
189
-
190
- key_vocab = {
191
- token: i + offset
192
- for i, token in enumerate(
193
- sorted(t for t, c in key_counts.items() if c >= min_freq)
194
- )
195
- }
196
-
197
- # Build value vocab: min_freq filtered, but always include kind values
198
- value_tokens = set(t for t, c in value_counts.items() if c >= value_min_freq)
199
- if kind_set:
200
- value_tokens.update(kind_set) # kinds always in value vocab
201
- value_vocab = {
202
- token: i + offset
203
- for i, token in enumerate(sorted(value_tokens))
204
- }
205
-
206
- atomic_target_vocab = {
207
- target: i + offset
208
- for i, target in enumerate(sorted(atomic_target_set or []))
209
- }
210
-
211
- return Vocabulary(
212
- key_vocab, value_vocab, special_tokens,
213
- atomic_target_vocab=atomic_target_vocab,
214
- )
215
 
216
  @staticmethod
217
- def save_counts(
218
- key_counts: dict[str, int],
219
- value_counts: dict[str, int],
220
- path: str,
221
- ) -> None:
222
- """Save raw token counts to a JSON file for reuse."""
223
- with open(path, "w") as f:
224
- json.dump({"key_counts": key_counts, "value_counts": value_counts}, f)
225
- print(f"Token counts saved: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
226
 
227
- @staticmethod
228
- def load_counts(path: str) -> tuple[dict[str, int], dict[str, int]]:
229
- """Load raw token counts from a JSON file."""
230
- with open(path) as f:
231
- data = json.load(f)
232
- key_counts: dict[str, int] = data["key_counts"]
233
- value_counts: dict[str, int] = data["value_counts"]
234
- print(f"Token counts loaded: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
235
- return key_counts, value_counts
236
-
237
- def build_from_huggingface(
238
- self,
239
- dataset_name: str,
240
- linearizer: "YamlLinearizer",
241
- annotator: "DomainAnnotator",
242
- max_docs: int | None = None,
243
- min_freq: int = 1,
244
- counts_path: str | None = None,
245
- ) -> Vocabulary:
246
- """Build vocabulary by scanning a HuggingFace dataset.
247
-
248
- Args:
249
- dataset_name: HuggingFace dataset ID
250
- linearizer: YamlLinearizer instance
251
- annotator: DomainAnnotator instance
252
- max_docs: Scan at most this many docs for vocab (None = all)
253
- min_freq: Minimum frequency to include a token
254
- counts_path: If set, save raw counts here (and load if file exists)
255
  """
256
- import os
257
-
258
- # Reuse saved counts if available
259
- if counts_path and os.path.exists(counts_path):
260
- key_counts, value_counts = self.load_counts(counts_path)
261
- return self.build_from_counts(key_counts, value_counts, min_freq, None)
262
-
263
- from datasets import load_dataset
264
- from yaml_bert.types import YamlNode
265
-
266
- print(f"Building vocabulary from: {dataset_name}")
267
- ds = load_dataset(dataset_name, split="train")
268
-
269
- total: int = len(ds) if max_docs is None else min(max_docs, len(ds))
270
- print(f"Scanning {total:,} / {len(ds):,} documents for vocabulary...")
271
-
272
- all_nodes: list[YamlNode] = []
273
- skipped: int = 0
274
- for i in range(total):
275
- try:
276
- nodes = linearizer.linearize(ds[i]["content"])
277
- except Exception:
278
- skipped += 1
279
- continue
280
- if nodes:
281
- annotator.annotate(nodes)
282
- all_nodes.extend(nodes)
283
-
284
- if (i + 1) % 10000 == 0:
285
- print(f" {i + 1:,} / {total:,} scanned ({len(all_nodes):,} nodes)")
286
-
287
- print(f"Scanned {total - skipped:,} docs, {len(all_nodes):,} nodes ({skipped} skipped)")
288
-
289
- # Compute counts
290
- key_counts: dict[str, int] = {}
291
- value_counts: dict[str, int] = {}
292
- kind_set: set[str] = set()
293
- prev_was_kind_key: bool = False
294
- for node in all_nodes:
295
- if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
296
- key_counts[node.token] = key_counts.get(node.token, 0) + 1
297
- prev_was_kind_key = (node.token == "kind" and node.depth == 0)
298
- elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
299
- value_counts[node.token] = value_counts.get(node.token, 0) + 1
300
- if prev_was_kind_key:
301
- kind_set.add(node.token)
302
- prev_was_kind_key = False
303
-
304
- # Save counts for reuse
305
- if counts_path:
306
- self.save_counts(key_counts, value_counts, counts_path)
307
-
308
- return self.build_from_counts(key_counts, value_counts, min_freq, kind_set)
 
1
+ """v9 Vocabulary: subword tokenizer + atomic_target_vocab.
2
+
3
+ The v8 key_vocab + value_vocab were merged into a single subword vocabulary
4
+ exposed via SubwordTokenizer. The Token Head still predicts over an atomic
5
+ target vocab built from frequent KEY tokens in the training corpus.
6
+ """
7
  from __future__ import annotations
8
 
9
  import json
10
+ from yaml_bert.tokenizer import SubwordTokenizer
 
 
 
11
 
12
  # Canonical casing for known Kubernetes kinds.
13
+ # Maps lowercase → canonical form. Populated by VocabBuilder.build_atomic_target_vocab.
14
  _KIND_CANONICAL: dict[str, str] = {}
15
 
16
 
17
  def normalize_kind(kind: str) -> str:
 
 
 
 
 
18
  return _KIND_CANONICAL.get(kind.lower(), kind)
19
 
20
 
21
  class Vocabulary:
22
+ """Holds a subword tokenizer + an atomic target vocab.
23
+
24
+ `atomic_target_vocab` maps whole KEY strings (e.g. "containers",
25
+ "restartPolicy") to integer class ids for the Token Head's output.
26
+ The 4 special tokens (pad/unk/mask/long_value) get the first 4 ids.
27
+ """
28
+
29
  def __init__(
30
  self,
31
+ tokenizer: SubwordTokenizer,
32
+ atomic_target_vocab: dict[str, int],
33
+ tokenizer_path: str | None = None,
 
34
  ) -> None:
35
+ self.tokenizer = tokenizer
36
+ self.atomic_target_vocab = atomic_target_vocab
37
+ self.tokenizer_path = tokenizer_path
38
+ # Convenience: expose the 4 special-token ids at the vocab level
39
+ self.pad_id = tokenizer.pad_id
40
+ self.unk_id = tokenizer.unk_id
41
+ self.mask_id = tokenizer.mask_id
42
+ self.long_value_id = tokenizer.long_value_id
43
 
44
+ @classmethod
45
+ def from_tokenizer_path(
46
+ cls,
47
+ tokenizer_path: str,
48
+ atomic_target_vocab: dict[str, int],
49
+ ) -> "Vocabulary":
50
+ return cls(
51
+ tokenizer=SubwordTokenizer.load(tokenizer_path),
52
+ atomic_target_vocab=atomic_target_vocab,
53
+ tokenizer_path=tokenizer_path,
54
+ )
 
 
 
 
 
 
 
55
 
56
+ def encode_token(self, token: str, *, is_value: bool) -> list[int]:
57
+ return self.tokenizer.encode_token(token, is_value=is_value)
 
 
 
58
 
59
+ def encode_atomic_target(self, key: str) -> int:
60
+ return self.atomic_target_vocab.get(key, self.unk_id)
 
61
 
62
  @property
63
+ def subword_vocab_size(self) -> int:
64
+ return self.tokenizer.vocab_size
65
 
66
  @property
67
  def atomic_target_vocab_size(self) -> int:
68
+ # +4 to reserve ids 0-3 for special tokens (consistent with v8 layout)
69
+ return len(self.atomic_target_vocab) + 4
70
 
71
  def save(self, path: str) -> None:
72
+ if self.tokenizer_path is None:
73
+ raise ValueError(
74
+ "Vocabulary.save requires tokenizer_path to be set "
75
+ "(use Vocabulary.from_tokenizer_path)."
76
+ )
 
77
  with open(path, "w") as f:
78
+ json.dump({
79
+ "tokenizer_path": self.tokenizer_path,
80
+ "atomic_target_vocab": self.atomic_target_vocab,
81
+ }, f, indent=2)
82
 
83
  @classmethod
84
+ def load(cls, path: str) -> "Vocabulary":
85
  with open(path) as f:
86
  data = json.load(f)
87
+ return cls.from_tokenizer_path(
88
+ tokenizer_path=data["tokenizer_path"],
89
+ atomic_target_vocab=data["atomic_target_vocab"],
 
 
90
  )
91
 
92
 
93
  class VocabBuilder:
94
+ """Builds the atomic_target_vocab from a corpus.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ The subword tokenizer is built separately by scripts/train_unified_tokenizer.py.
97
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  @staticmethod
100
+ def build_atomic_target_vocab(
101
+ nodes_per_doc: list[list],
102
+ min_freq: int,
103
+ ) -> dict[str, int]:
104
+ """Scan KEY tokens across docs, return {token: id} for keys appearing >= min_freq times.
 
 
 
 
105
 
106
+ IDs start at 4 (0-3 are reserved for special tokens).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  """
108
+ from yaml_bert.types import NodeType
109
+ counts: dict[str, int] = {}
110
+ for nodes in nodes_per_doc:
111
+ for n in nodes:
112
+ if n.node_type in (NodeType.KEY, NodeType.LIST_KEY):
113
+ counts[n.token] = counts.get(n.token, 0) + 1
114
+
115
+ # Also populate kind-canonical map as a side effect (used by suggest.py)
116
+ prev_kind_key = False
117
+ for nodes in nodes_per_doc:
118
+ for n in nodes:
119
+ if n.node_type in (NodeType.KEY, NodeType.LIST_KEY):
120
+ prev_kind_key = (n.token == "kind" and n.depth == 0)
121
+ elif n.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
122
+ if prev_kind_key:
123
+ lower = n.token.lower()
124
+ existing = _KIND_CANONICAL.get(lower)
125
+ if existing is None:
126
+ _KIND_CANONICAL[lower] = n.token
127
+ prev_kind_key = False
128
+
129
+ kept = sorted(t for t, c in counts.items() if c >= min_freq)
130
+ return {t: 4 + i for i, t in enumerate(kept)}