Skip to content

Commit 2253f64

Browse files
committed
fix(objective): exhaustive matcher fallback for repeated quota slots
The greedy forced-candidate matcher could miss valid assignments when repeated quota slots (e.g. FIM:2) forced a graph-positive packet to always pair with the same greedy-first partner. Add an exhaustive itertools-based fallback that triggers only when the fast path fails and required_realized_assignment is active. Regression: seeds 6/71 with ppgp fixture no longer raise ObjectiveQuotaUnsatisfiedError.
1 parent 9432a2e commit 2253f64

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

cppmega_mlx/training/objective_mixer.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,7 +884,43 @@ def realize(packet_owner: Sequence[int | None]) -> list[RealizedObjective]:
884884
):
885885
return realized
886886

887+
# Exhaustive fallback: the greedy augmenting-path matcher explores only
888+
# a narrow set of realizations because candidate_packets ordering is
889+
# fixed. When required_realized_assignment is active and the initial
890+
# sweep failed, enumerate all valid packet-to-slot matchings to avoid
891+
# a false ObjectiveQuotaUnsatisfiedError (CR-01).
887892
if required_realized_assignment is not None and matched_assignment:
893+
import itertools
894+
895+
num_slots = len(slots)
896+
pool_indices = list(range(len(packets)))
897+
for combo in itertools.combinations(pool_indices, num_slots):
898+
# Try all permutations of slot assignments for this packet combo
899+
for perm in itertools.permutations(range(num_slots)):
900+
# Check eligibility: packet combo[i] must be eligible for
901+
# the task of slot perm[i]
902+
valid = all(
903+
slots[perm[i]] in eligibility[combo[i]][0]
904+
for i in range(num_slots)
905+
)
906+
if not valid:
907+
continue
908+
# Build packet_owner for this assignment
909+
exhaustive_owner: list[int | None] = [None] * len(packets)
910+
for i, packet_idx in enumerate(combo):
911+
exhaustive_owner[packet_idx] = perm[i]
912+
signature = tuple(exhaustive_owner)
913+
if signature in attempted_assignments:
914+
continue
915+
attempted_assignments.add(signature)
916+
realized = realize(exhaustive_owner)
917+
if any(
918+
required_realized_assignment(
919+
packets[item.source_index], item
920+
)
921+
for item in realized
922+
):
923+
return realized
888924
raise ObjectiveQuotaUnsatisfiedError(
889925
"objective quota matching produced no realized assignment "
890926
"satisfying the required auxiliary constraint: "
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Regression test for CR-01: objective mixer matcher incompleteness.
2+
3+
The greedy bipartite matching in materialize_window_from_pool explores only a
4+
narrow set of forced assignments when required_realized_assignment is set. With
5+
repeated quota slots the matcher always pairs a forced packet with the same
6+
greedy-first-choice partner, so valid assignments (e.g. source indices 2, 3)
7+
are never explored, causing a false ObjectiveQuotaUnsatisfiedError.
8+
9+
Reproduction: ppgp source-pool pattern, FIM objective, output_count=2,
10+
seeds 6 and 71.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from dataclasses import replace
16+
17+
import mlx.core as mx
18+
import numpy as np
19+
import pytest
20+
21+
from cppmega_mlx.data.code_packet import CodePacket
22+
from cppmega_mlx.data.domain_packet import DomainEdgeIndex
23+
from cppmega_mlx.data.graph_packet import EdgeIndex
24+
from cppmega_mlx.training.objective_mixer import (
25+
EligibilityAwareTaskMixer,
26+
ObjectiveQuotaUnsatisfiedError,
27+
ObjectiveSource,
28+
)
29+
from cppmega_mlx.training.objective_schedule import (
30+
assess_graph_positive_capability,
31+
source_has_graph_candidate,
32+
)
33+
from cppmega_mlx.training.task_mixer import TaskKind
34+
35+
36+
def _arr(values: list[int]) -> mx.array:
37+
return mx.array(np.asarray(values, dtype=np.int32))
38+
39+
40+
def _code_packet(*, domain_edge: tuple[int, int, int] | None = None) -> CodePacket:
41+
"""Minimal FIM-eligible code packet with optional domain edge."""
42+
token_count = 8
43+
zeros = _arr([0] * token_count)
44+
domain_edges = DomainEdgeIndex.empty()
45+
if domain_edge is not None:
46+
domain_edges = DomainEdgeIndex.from_triples([domain_edge])
47+
return CodePacket(
48+
token_ids=_arr(list(range(100, 100 + token_count))),
49+
document_ids=_arr([1] * token_count),
50+
ifim_instruction_token_ids=_arr([1201, 1202]),
51+
structure_ids=_arr([1] * token_count),
52+
dep_levels=zeros,
53+
ast_depth=zeros,
54+
sibling_index=zeros,
55+
ast_node_type=_arr([1] * token_count),
56+
symbol_ids=zeros,
57+
call_targets=zeros,
58+
type_refs=zeros,
59+
def_use=zeros,
60+
domain_ids=zeros,
61+
role_ids=zeros,
62+
entity_ids=zeros,
63+
scope_ids=zeros,
64+
confidence_ids=_arr([1] * token_count),
65+
source_doc_ids=_arr([1] * token_count),
66+
source_identity_ids=_arr([1] * token_count),
67+
chunk_starts=_arr([0, 2, 5]),
68+
chunk_ends=_arr([2, 5, 8]),
69+
chunk_kinds=_arr([1, 1, 1]),
70+
chunk_dep_levels=_arr([0, 0, 0]),
71+
call_edges=EdgeIndex.from_pairs([], relation="call", num_nodes=3),
72+
type_edges=EdgeIndex.from_pairs([], relation="type", num_nodes=3),
73+
domain_edges=domain_edges,
74+
metadata={"platform_ids": [2]},
75+
)
76+
77+
78+
def _ppgp_sources() -> list[ObjectiveSource]:
79+
"""ppgp pattern: plain, plain, graph-positive, plain.
80+
81+
All four sources are FIM-eligible code packets. Source index 2 carries a
82+
domain edge making it graph-positive.
83+
"""
84+
return [
85+
ObjectiveSource(code_packet=_code_packet()), # 0: plain
86+
ObjectiveSource(code_packet=_code_packet()), # 1: plain
87+
ObjectiveSource(code_packet=_code_packet(domain_edge=(4, 1, 60))), # 2: graph-positive
88+
ObjectiveSource(code_packet=_code_packet()), # 3: plain
89+
]
90+
91+
92+
@pytest.mark.parametrize("seed", [6, 71])
93+
def test_ppgp_fim_output2_no_false_quota_error(seed: int) -> None:
94+
"""CR-01: matcher must not raise ObjectiveQuotaUnsatisfiedError.
95+
96+
With the ppgp pool, FIM objective, and output_count=2, the assignment
97+
(source indices 2, 3) satisfies quotas and contains a graph-positive item.
98+
The matcher must find a satisfying assignment rather than raising.
99+
"""
100+
sources = _ppgp_sources()
101+
102+
def graph_positive(source: ObjectiveSource, item) -> bool:
103+
receipt = assess_graph_positive_capability(
104+
item,
105+
source,
106+
graph_relations=("domain",),
107+
require_route_sidecars=False,
108+
)
109+
return bool(receipt["eligible"])
110+
111+
# Must not raise ObjectiveQuotaUnsatisfiedError
112+
realized = EligibilityAwareTaskMixer(
113+
{TaskKind.FIM: 1.0},
114+
seed=seed,
115+
).materialize_window_from_pool(
116+
sources,
117+
output_count=2,
118+
required_realized_assignment=graph_positive,
119+
candidate_assignment=lambda source, task: source_has_graph_candidate(
120+
source,
121+
task,
122+
graph_relations=("domain",),
123+
),
124+
)
125+
126+
# Quotas must be exactly satisfied: 2 FIM samples
127+
assert len(realized) == 2
128+
assert all(item.task == TaskKind.FIM for item in realized)
129+
130+
# At least one returned assignment must be graph-positive
131+
graph_positive_items = [
132+
item
133+
for item in realized
134+
if graph_positive(sources[item.source_index], item)
135+
]
136+
assert len(graph_positive_items) >= 1, (
137+
"expected at least one graph-positive realized assignment"
138+
)
139+
140+
141+
@pytest.mark.parametrize("seed", [6, 71])
142+
def test_ppgp_fim_output2_source_2_3_admissible(seed: int) -> None:
143+
"""Source indices (2, 3) remain an admissible solution.
144+
145+
Manually verify that the mixer can produce an assignment using sources 2
146+
and 3 when the search space is not artificially restricted.
147+
"""
148+
sources = _ppgp_sources()
149+
150+
def graph_positive(source: ObjectiveSource, item) -> bool:
151+
receipt = assess_graph_positive_capability(
152+
item,
153+
source,
154+
graph_relations=("domain",),
155+
require_route_sidecars=False,
156+
)
157+
return bool(receipt["eligible"])
158+
159+
realized = EligibilityAwareTaskMixer(
160+
{TaskKind.FIM: 1.0},
161+
seed=seed,
162+
).materialize_window_from_pool(
163+
sources,
164+
output_count=2,
165+
required_realized_assignment=graph_positive,
166+
candidate_assignment=lambda source, task: source_has_graph_candidate(
167+
source,
168+
task,
169+
graph_relations=("domain",),
170+
),
171+
)
172+
173+
selected = sorted(item.source_index for item in realized)
174+
# The assignment must include the graph-positive source (index 2)
175+
assert 2 in selected, (
176+
f"graph-positive source 2 must be selected, got {selected}"
177+
)

0 commit comments

Comments
 (0)