@@ -37,15 +37,50 @@ import (
3737type 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.
4348var 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 {
225260func (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.
281342func criteriaDimensionValue (c * Criteria , dimName string ) string {
282343 for _ , dim := range coverageDimensions {
@@ -344,3 +405,200 @@ func sameDimensionSingletons(tuples []map[string]string) (string, []string, bool
344405func 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