Skip to content

Commit 3c2c076

Browse files
committed
fix(recipe): attach exclusion context to strict-gap errors; pin rendering
Signed-off-by: Brian Lockwood <lockwobr@gmail.com>
1 parent 0132f46 commit 3c2c076

3 files changed

Lines changed: 123 additions & 10 deletions

File tree

docs/contributor/recipe.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ field before upgrading.
480480
sufficient. It is satisfied when `service` and `accelerator` are each honored
481481
by *some* overlay independently, even when no single overlay carries the
482482
combination and the combination's content lives only on an OS-gated leaf. The
483-
caller then receives a recipe that silently never applied it.
483+
caller then receives a recipe that silently omits that OS-gated content.
484484
`verifyCriteriaCoverage` therefore also enforces a second condition
485485
(issue #1782): resolution fails when **no applied overlay jointly carries
486486
every stated dimension** *and* stating a strict dimension would reach an

pkg/recipe/coverage.go

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,20 @@ var coverageDimensions = []coverageDimension{
6565
// hand back. Resolving one anyway would emit a recipe whose driver story
6666
// is wrong rather than merely generic.
6767
//
68-
// That is a property of installing NVIDIA drivers on Linux, not of this
69-
// catalog's shape, so it holds for external --data catalogs too. The
70-
// assumption that would break it is a cluster whose node pools run
71-
// different operating systems; AICR models that as separate recipes
72-
// today (see ADR-018, which partitions a resolved recipe by component
73-
// class and explicitly does not change resolution).
68+
// The driver argument is a property of installing NVIDIA drivers on Linux
69+
// rather than of this catalog's shape, so it carries to external --data
70+
// catalogs. Note what that does and does not claim: it says os is the
71+
// right dimension to demand, NOT that every catalog shape is served well
72+
// by demanding it. A split-coverage external catalog (service overlay,
73+
// os-agnostic accelerator overlay, one os-gated tuned leaf) is rejected
74+
// here asking for an os, because no single overlay carries the stated
75+
// combination. That is deliberate and the escape hatch is explicit:
76+
// declare an os-agnostic overlay carrying the combination, which is the
77+
// same assertion eks.yaml makes.
78+
//
79+
// The assumption that would break the driver argument itself is a cluster
80+
// whose node pools run different operating systems. AICR has no model for
81+
// that; it is expressed as separate recipes.
7482
{name: string(FieldOS), value: func(c *Criteria) string { return string(c.OS) }, strict: true},
7583
{name: string(FieldPlatform), value: func(c *Criteria) string { return string(c.Platform) }},
7684
}
@@ -258,7 +266,7 @@ func (s *MetadataStore) verifyCriteriaCoverage(criteria *Criteria, appliedOverla
258266
// overlay covers the combination. Joint sufficiency catches that, and
259267
// absorbs the retired requireOSIfNeeded guard (issue #1782).
260268
if gaps := s.strictDimensionGaps(criteria, appliedOverlays); len(gaps) > 0 {
261-
return strictGapError(criteria, gaps)
269+
return strictGapError(criteria, gaps, excluded, warnings)
262270
}
263271
return nil
264272
}
@@ -562,7 +570,16 @@ func (s *MetadataStore) inheritanceChainNames(overlay *RecipeMetadata) []string
562570
// context uses its own key rather than `uncovered`: pkg/client/v1 relaxation
563571
// CLEARS uncovered dimensions and retries, which here would discard the check
564572
// and return the partial recipe that issue #1542 fixed.
565-
func strictGapError(criteria *Criteria, gaps []strictGap) error {
573+
//
574+
// excluded/warnings are attached exactly as the completeness path attaches
575+
// them. reachesUnappliedOverlay probes through the UNFILTERED overlay set, so
576+
// on the evaluator path an overlay that would cover the combination but was
577+
// removed by a failing constraint still counts as reachable. Without this
578+
// context the caller is told to state an os, supplies it, and only then meets
579+
// the real constraint failure. The demand itself is still correct — the
580+
// combination genuinely is not covered — so this is a diagnosis aid, not a
581+
// gate: the error stands either way.
582+
func strictGapError(criteria *Criteria, gaps []strictGap, excluded []ExcludedOverlay, warnings []ConstraintWarning) error {
566583
clauses := make([]string, 0, len(gaps))
567584
entries := make([]map[string]any, 0, len(gaps))
568585
for _, gap := range gaps {
@@ -573,8 +590,15 @@ func strictGapError(criteria *Criteria, gaps []strictGap) error {
573590
"validValues": gap.validValues,
574591
})
575592
}
593+
ctx := map[string]any{"strictDimensions": entries}
594+
if len(excluded) > 0 {
595+
ctx["excludedOverlays"] = excluded
596+
}
597+
if len(warnings) > 0 {
598+
ctx["constraintWarnings"] = warnings
599+
}
576600
return aicrerrors.NewWithContext(aicrerrors.ErrCodeInvalidRequest,
577601
fmt.Sprintf("%s has no recipe covering that combination; specify %s",
578602
criteria.String(), strings.Join(clauses, ", ")),
579-
map[string]any{"strictDimensions": entries})
603+
ctx)
580604
}

pkg/recipe/coverage_subsumption_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ package recipe
1616

1717
import (
1818
"context"
19+
stderrors "errors"
1920
"fmt"
2021
"sort"
22+
"strings"
2123
"testing"
24+
25+
aicrerrors "github.com/NVIDIA/aicr/pkg/errors"
2226
)
2327

2428
// retiredOSGuard is the deleted requireOSIfNeeded, preserved verbatim as a
@@ -161,3 +165,88 @@ func TestJointSufficiencySubsumesRetiredGuard(t *testing.T) {
161165
}
162166
t.Logf("subsumption held: retired guard fired on %d (catalog, query) pairs, all still rejected", guardFired)
163167
}
168+
169+
// TestStrictGapErrorRendering pins the strict-gap message and context shape
170+
// directly, independent of catalog shape.
171+
//
172+
// Both are otherwise under-exercised. The embedded catalog produces zero
173+
// joint-sufficiency failures — completeness always fires first — so
174+
// testdata/coverage_golden.yaml has no strictDimensions entries and the
175+
// golden helper's extraction branch never runs against real data. And the
176+
// multi-value join executes only when one dimension has several reaching
177+
// values, which the single-strict-dimension catalog cannot produce.
178+
func TestStrictGapErrorRendering(t *testing.T) {
179+
criteria := &Criteria{Service: CriteriaServiceEKS, Accelerator: CriteriaAcceleratorH100}
180+
181+
tests := []struct {
182+
name string
183+
gaps []strictGap
184+
excluded []ExcludedOverlay
185+
wantContains []string
186+
wantExcluded bool
187+
}{
188+
{
189+
name: "single value",
190+
gaps: []strictGap{{dimension: string(FieldOS), validValues: []string{"ubuntu"}}},
191+
wantContains: []string{"specify os (valid: ubuntu)"},
192+
},
193+
{
194+
name: "multiple values join with a comma",
195+
gaps: []strictGap{{dimension: string(FieldOS), validValues: []string{"cos", "ubuntu"}}},
196+
wantContains: []string{"specify os (valid: cos, ubuntu)"},
197+
},
198+
{
199+
name: "multiple gaps join with a comma",
200+
gaps: []strictGap{
201+
{dimension: string(FieldOS), validValues: []string{"ubuntu"}},
202+
{dimension: string(FieldIntent), validValues: []string{"training"}},
203+
},
204+
wantContains: []string{"os (valid: ubuntu)", "intent (valid: training)"},
205+
},
206+
{
207+
// On the evaluator path a covering overlay may have been removed by
208+
// a failing constraint. The demand still stands, but the caller
209+
// needs the exclusion context or they will state the os and only
210+
// then meet the real failure.
211+
name: "constraint exclusions are attached",
212+
gaps: []strictGap{{dimension: string(FieldOS), validValues: []string{"ubuntu"}}},
213+
excluded: []ExcludedOverlay{{Name: "h100-eks-ubuntu-training"}},
214+
wantContains: []string{"specify os (valid: ubuntu)"},
215+
wantExcluded: true,
216+
},
217+
}
218+
219+
for _, tt := range tests {
220+
t.Run(tt.name, func(t *testing.T) {
221+
err := strictGapError(criteria, tt.gaps, tt.excluded, nil)
222+
for _, want := range tt.wantContains {
223+
if !strings.Contains(err.Error(), want) {
224+
t.Errorf("message = %q, want it to contain %q", err.Error(), want)
225+
}
226+
}
227+
228+
var se *aicrerrors.StructuredError
229+
if !stderrors.As(err, &se) {
230+
t.Fatalf("expected StructuredError, got %v", err)
231+
}
232+
if se.Context["uncovered"] != nil {
233+
t.Error("strict-gap failure must not populate `uncovered`; relaxation would clear it")
234+
}
235+
if (se.Context["excludedOverlays"] != nil) != tt.wantExcluded {
236+
t.Errorf("excludedOverlays present = %v, want %v",
237+
se.Context["excludedOverlays"] != nil, tt.wantExcluded)
238+
}
239+
240+
// The golden matrix classifies via this extraction, so exercise it
241+
// here rather than relying on catalog shape to reach it.
242+
got := strictDimensionsFromError(err)
243+
want := make([]string, 0, len(tt.gaps))
244+
for _, g := range tt.gaps {
245+
want = append(want, g.dimension)
246+
}
247+
if !equalStrings(got, want) {
248+
t.Errorf("strictDimensionsFromError = %v, want %v", got, want)
249+
}
250+
})
251+
}
252+
}

0 commit comments

Comments
 (0)