Skip to content

Commit 619cf93

Browse files
authored
fix(recipe): fold the OS guard into one joint-coverage rule (#2322)
Signed-off-by: Brian Lockwood <lockwobr@gmail.com>
1 parent 7ba3ac9 commit 619cf93

7 files changed

Lines changed: 1195 additions & 193 deletions

File tree

docs/contributor/recipe.md

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,14 +492,44 @@ on any overlay are rejected at load time (`ErrCodeInvalidRequest`) to
492492
prevent silent match-all behaviour; operators must remove or zero that
493493
field before upgrading.
494494

495-
**Composition with the OS guard.** `requireOSIfNeeded` (the joint
496-
service+accelerator OS gate) is a separate, pre-existing check and runs
497-
*first*, before the merge. It is **not subsumed** by the coverage
498-
post-condition: coverage is satisfied when service and accelerator are each
499-
honored by *some* overlay independently, while the OS guard demands *one*
500-
overlay carry both service and accelerator together before it will consider
501-
the OS-agnostic tier served. Both checks apply; a request can trip either
502-
one independently.
495+
**Joint sufficiency.** Per-dimension coverage is necessary but not
496+
sufficient. It is satisfied when `service` and `accelerator` are each honored
497+
by *some* overlay independently, even when no single overlay carries the
498+
combination and the combination's content lives only on an OS-gated leaf. The
499+
caller then receives a recipe that silently omits that OS-gated content.
500+
`verifyCriteriaCoverage` therefore also enforces a second condition
501+
(issue #1782): resolution fails when **no applied overlay jointly carries
502+
every stated dimension** *and* stating a strict dimension would reach an
503+
overlay currently being skipped.
504+
505+
Both halves matter. The first is the escape hatch that keeps the generic tier
506+
valid: `--service eks` resolves through `eks.yaml`, which carries the whole
507+
stated combination, and is never asked for an OS. The second is what detects
508+
the loss.
509+
510+
`os` is the only **strict** dimension, and `coverage.go` records why. Every
511+
other dimension degrades to a smaller but coherent recipe when omitted: no
512+
`--platform` yields no Slurm or Kubeflow layer, no `--intent` yields untuned
513+
GPU Operator values. `os` decides whether the driver can be installed at all.
514+
On Ubuntu the GPU Operator installs it, so an OS-agnostic recipe is a real
515+
answer and `eks.yaml` carries no `os`; on COS the operator installs no driver
516+
and the device-plugin owner differs, which is why every `gke` overlay is
517+
OS-gated and no OS-agnostic GKE recipe exists to return. That is a property of
518+
installing NVIDIA drivers on Linux rather than of this catalog's shape, so it
519+
holds for external `--data` catalogs too.
520+
521+
This condition replaced the `requireOSIfNeeded` guard, which ran before the
522+
merge and hardcoded three separate scopes: it only fired when `service` was
523+
stated, only compared `service`+`accelerator` regardless of what the caller
524+
asked for, and only ever demanded `os`. Only the last survives.
525+
`coverage_subsumption_test.go` keeps the retired guard as a test-only oracle
526+
and asserts over generated catalogs that every query it would have rejected is
527+
still rejected.
528+
529+
A joint-sufficiency failure carries `details.strictDimensions`, **not**
530+
`details.uncovered`. The distinction is load-bearing: `pkg/client/v1`
531+
relaxation clears uncovered dimensions and retries, which here would discard
532+
the check and return the partial recipe that #1542 fixed.
503533

504534
**Evaluator error classification is fail-closed.** During constraint
505535
evaluation on the snapshot-driven path, `ErrCodeNotFound` (the evaluator's

pkg/recipe/coverage.go

Lines changed: 263 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,50 @@ import (
3737
type coverageDimension struct {
3838
name string
3939
value func(*Criteria) string
40+
41+
// strict marks a dimension the caller must state when omitting it would
42+
// resolve to a weaker recipe than the catalog can produce. See
43+
// strictDimensionGaps for the rule and why os is the only one.
44+
strict bool
4045
}
4146

4247
// coverageDimensions is ordered; all coverage reporting uses this order.
4348
var coverageDimensions = []coverageDimension{
44-
{"service", func(c *Criteria) string { return string(c.Service) }},
45-
{"accelerator", func(c *Criteria) string { return string(c.Accelerator) }},
46-
{"intent", func(c *Criteria) string { return string(c.Intent) }},
47-
{"os", func(c *Criteria) string { return string(c.OS) }},
48-
{"platform", func(c *Criteria) string { return string(c.Platform) }},
49+
{name: string(FieldService), value: func(c *Criteria) string { return string(c.Service) }},
50+
{name: string(FieldAccelerator), value: func(c *Criteria) string { return string(c.Accelerator) }},
51+
{name: string(FieldIntent), value: func(c *Criteria) string { return string(c.Intent) }},
52+
// os is the only strict dimension, and the reason is the driver.
53+
//
54+
// Every other dimension degrades to a smaller but coherent recipe when
55+
// omitted: no --platform yields no Slurm/Kubeflow layer, no --intent
56+
// yields untuned GPU Operator values, no --accelerator yields generic
57+
// GPU config. Each answer is complete, just less specific.
58+
//
59+
// os is different because it decides whether the GPU driver can be
60+
// installed at all. On Ubuntu the GPU Operator installs the driver, so
61+
// an OS-agnostic recipe is a real answer; that is why recipes/overlays
62+
// carries eks.yaml with no os. On COS the operator installs no driver
63+
// (Google supplies it) and the device-plugin owner differs, which is why
64+
// every gke overlay is os-gated and no OS-agnostic gke recipe exists to
65+
// hand back. Resolving one anyway would emit a recipe whose driver story
66+
// is wrong rather than merely generic.
67+
//
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.
82+
{name: string(FieldOS), value: func(c *Criteria) string { return string(c.OS) }, strict: true},
83+
{name: string(FieldPlatform), value: func(c *Criteria) string { return string(c.Platform) }},
4984
}
5085

5186
// CoverageDimensionNames returns the criteria dimension names subject to the
@@ -225,6 +260,14 @@ func isSubsetTuple(sub, super map[string]string) bool {
225260
func (s *MetadataStore) verifyCriteriaCoverage(criteria *Criteria, appliedOverlays []string, excluded []ExcludedOverlay, warnings []ConstraintWarning) error {
226261
uncovered := s.uncoveredDimensions(criteria, appliedOverlays)
227262
if len(uncovered) == 0 {
263+
// Completeness holds: every stated dimension is carried by something.
264+
// That is necessary but not sufficient — it is satisfied when service
265+
// and accelerator are honored by two SEPARATE overlays while no single
266+
// overlay covers the combination. Joint sufficiency catches that, and
267+
// absorbs the retired requireOSIfNeeded guard (issue #1782).
268+
if gaps := s.strictDimensionGaps(criteria, appliedOverlays); len(gaps) > 0 {
269+
return strictGapError(criteria, gaps, excluded, warnings)
270+
}
228271
return nil
229272
}
230273

@@ -277,6 +320,24 @@ func (s *MetadataStore) excludedOverlayProvides(dimName, want string, excluded [
277320
return false
278321
}
279322

323+
// setCriteriaDimension writes one named dimension's value. Write-side twin of
324+
// criteriaDimensionValue; strictDimensionGaps uses it to probe what the
325+
// applied set would look like had the caller stated a dimension.
326+
func setCriteriaDimension(c *Criteria, name, value string) {
327+
switch name {
328+
case string(FieldService):
329+
c.Service = CriteriaServiceType(value)
330+
case string(FieldAccelerator):
331+
c.Accelerator = CriteriaAcceleratorType(value)
332+
case string(FieldIntent):
333+
c.Intent = CriteriaIntentType(value)
334+
case string(FieldOS):
335+
c.OS = CriteriaOSType(value)
336+
case string(FieldPlatform):
337+
c.Platform = CriteriaPlatformType(value)
338+
}
339+
}
340+
280341
// criteriaDimensionValue reads one named dimension's value.
281342
func criteriaDimensionValue(c *Criteria, dimName string) string {
282343
for _, dim := range coverageDimensions {
@@ -344,3 +405,200 @@ func sameDimensionSingletons(tuples []map[string]string) (string, []string, bool
344405
func isNotFoundEvalError(err error) bool {
345406
return stderrors.Is(err, aicrerrors.New(aicrerrors.ErrCodeNotFound, ""))
346407
}
408+
409+
// strictGap names a strict dimension the caller must state, with the values
410+
// that would reach the overlays currently being skipped.
411+
type strictGap struct {
412+
dimension string
413+
validValues []string
414+
}
415+
416+
// jointlyCarriesAllStated reports whether some applied overlay carries every
417+
// dimension the query states, with matching values.
418+
//
419+
// This is the escape hatch that keeps the generic tier valid. When one
420+
// overlay already honors the whole stated combination, nothing was silently
421+
// dropped and no further criteria are demanded — `--service eks` resolves
422+
// through eks.yaml and is never asked for an os. Per-dimension coverage
423+
// cannot express this: it is satisfied when service and accelerator are
424+
// honored by two SEPARATE overlays, which is a pile of ingredients rather
425+
// than the recipe for the combination (issue #1782).
426+
func (s *MetadataStore) jointlyCarriesAllStated(criteria *Criteria, applied []string) bool {
427+
for _, name := range applied {
428+
meta, ok := s.GetRecipeByName(name)
429+
if !ok || meta.Spec.Criteria == nil {
430+
continue
431+
}
432+
carriesAll := true
433+
for _, dim := range coverageDimensions {
434+
want := dim.value(criteria)
435+
if !isSpecifiedCriteriaValue(want) {
436+
continue
437+
}
438+
if dim.value(meta.Spec.Criteria) != want {
439+
carriesAll = false
440+
break
441+
}
442+
}
443+
if carriesAll {
444+
return true
445+
}
446+
}
447+
return false
448+
}
449+
450+
// strictDimensionGaps returns the strict dimensions the caller must state,
451+
// or nil when resolution may proceed.
452+
//
453+
// The rule, replacing the retired requireOSIfNeeded guard:
454+
//
455+
// Resolution fails when NO applied overlay jointly carries every stated
456+
// dimension AND stating a strict dimension would reach an overlay that is
457+
// currently being skipped.
458+
//
459+
// Both halves are required. The first is jointlyCarriesAllStated above. The
460+
// second is what detects the loss: if naming an os would pull in an overlay
461+
// that is not applied, that overlay's content is being dropped by omission
462+
// rather than by choice.
463+
//
464+
// The demand is for PRESENCE, not for a particular value. validValues is
465+
// advisory, matching the retired guard's "specify an OS (valid: cos)"
466+
// wording; supplying a value outside it is legal and falls through to the
467+
// ordinary completeness path above.
468+
//
469+
// requireOSIfNeeded hardcoded three separate scopes: it only ran when
470+
// service was stated, it only compared service+accelerator regardless of
471+
// what the caller actually asked for, and it only ever demanded os. Only the
472+
// third survives here, and only for the reason recorded on coverageDimensions.
473+
// The subset now comes from the query.
474+
func (s *MetadataStore) strictDimensionGaps(criteria *Criteria, applied []string) []strictGap {
475+
if s.jointlyCarriesAllStated(criteria, applied) {
476+
return nil
477+
}
478+
479+
appliedSet := make(map[string]struct{}, len(applied))
480+
for _, name := range applied {
481+
appliedSet[name] = struct{}{}
482+
}
483+
484+
gaps := make([]strictGap, 0, len(coverageDimensions))
485+
for _, dim := range coverageDimensions {
486+
if !dim.strict || isSpecifiedCriteriaValue(dim.value(criteria)) {
487+
continue // elective, or the caller already stated it
488+
}
489+
reachable := map[string]struct{}{}
490+
for _, value := range s.dimensionValues(dim) {
491+
probe := *criteria
492+
setCriteriaDimension(&probe, dim.name, value)
493+
// One overlay outside the applied set is enough to establish that
494+
// this value reaches something currently being skipped; the rest
495+
// of the matches and their chains cannot change the answer.
496+
if s.reachesUnappliedOverlay(&probe, appliedSet) {
497+
reachable[value] = struct{}{}
498+
}
499+
}
500+
if len(reachable) == 0 {
501+
continue
502+
}
503+
values := make([]string, 0, len(reachable))
504+
for v := range reachable {
505+
values = append(values, v)
506+
}
507+
sort.Strings(values)
508+
gaps = append(gaps, strictGap{dimension: dim.name, validValues: values})
509+
}
510+
if len(gaps) == 0 {
511+
return nil
512+
}
513+
return gaps
514+
}
515+
516+
// reachesUnappliedOverlay reports whether resolving probe would pull in any
517+
// overlay that is not already applied.
518+
func (s *MetadataStore) reachesUnappliedOverlay(probe *Criteria, applied map[string]struct{}) bool {
519+
for _, match := range s.FindMatchingOverlays(probe) {
520+
for _, name := range s.inheritanceChainNames(match) {
521+
if _, already := applied[name]; !already {
522+
return true
523+
}
524+
}
525+
}
526+
return false
527+
}
528+
529+
// dimensionValues returns every value the catalog declares for a dimension,
530+
// sorted. Iteration order of s.Overlays is randomized, so the sort is what
531+
// makes strictDimensionGaps deterministic.
532+
func (s *MetadataStore) dimensionValues(dim coverageDimension) []string {
533+
seen := map[string]struct{}{}
534+
for _, overlay := range s.Overlays {
535+
if overlay.Spec.Criteria == nil {
536+
continue
537+
}
538+
if v := dim.value(overlay.Spec.Criteria); isSpecifiedCriteriaValue(v) {
539+
seen[v] = struct{}{}
540+
}
541+
}
542+
values := make([]string, 0, len(seen))
543+
for v := range seen {
544+
values = append(values, v)
545+
}
546+
sort.Strings(values)
547+
return values
548+
}
549+
550+
// inheritanceChainNames returns the overlay and every ancestor it inherits
551+
// from, matching the appliedOverlays semantics used by coverage.
552+
func (s *MetadataStore) inheritanceChainNames(overlay *RecipeMetadata) []string {
553+
names := []string{}
554+
for cur := overlay; cur != nil; {
555+
names = append(names, cur.Metadata.Name)
556+
if cur.Spec.Base == "" {
557+
break
558+
}
559+
next, ok := s.GetRecipeByName(cur.Spec.Base)
560+
if !ok {
561+
break
562+
}
563+
cur = next
564+
}
565+
return names
566+
}
567+
568+
// strictGapError renders the strict-dimension failure. The message mirrors
569+
// the retired guard so operator-facing wording does not regress, and the
570+
// context uses its own key rather than `uncovered`: pkg/client/v1 relaxation
571+
// CLEARS uncovered dimensions and retries, which here would discard the check
572+
// and return the partial recipe that issue #1542 fixed.
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 {
583+
clauses := make([]string, 0, len(gaps))
584+
entries := make([]map[string]any, 0, len(gaps))
585+
for _, gap := range gaps {
586+
clauses = append(clauses, fmt.Sprintf("%s (valid: %s)",
587+
gap.dimension, strings.Join(gap.validValues, ", ")))
588+
entries = append(entries, map[string]any{
589+
"dimension": gap.dimension,
590+
"validValues": gap.validValues,
591+
})
592+
}
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+
}
600+
return aicrerrors.NewWithContext(aicrerrors.ErrCodeInvalidRequest,
601+
fmt.Sprintf("%s has no recipe covering that combination; specify %s",
602+
criteria.String(), strings.Join(clauses, ", ")),
603+
ctx)
604+
}

0 commit comments

Comments
 (0)