using System.Collections.Frozen;
using AIStudio.Provider;
namespace AIStudio.Models.Matching;
///
/// Answers what is known about a model name, out of all the rules there are.
///
///
/// The old rules asked every question in turn: a name arriving at the open weights block walked
/// past more than a hundred string comparisons before anything answered it, and it did so on every
/// render of every component which shows a provider. Here the name is cut into its parts and each
/// part looks up the handful of rules which mention it, so a name is measured against the rules
/// which could possibly apply to it and against nothing else.
///
/// Building the index costs a sort and a dictionary; that happens once. Answering allocates a small
/// list when several rules apply, which is the cold path -- the registry keeps the answers, so the
/// same model is not resolved twice.
///
/// Nothing here reaches for application state. A test can build an index and ask it questions
/// without the app ever having started.
///
public sealed class ModelFamilyIndex
{
private readonly FrozenDictionary.AlternateLookup> byNamePartLookup;
private readonly bool canLookUpNameParts;
private readonly ModelRule[] alwaysChecked;
private ModelFamilyIndex(ModelRule[] rules, FrozenDictionary byNamePart, ModelRule[] alwaysChecked, IReadOnlyList ambiguities)
{
this.alwaysChecked = alwaysChecked;
this.Rules = rules;
this.Ambiguities = ambiguities;
//
// Looking a name part up as a span rather than as a string is what keeps the lookup free of
// allocations. It needs a comparer which knows how to hash a span, and an index holding no
// rules at all has no comparer to speak of -- there is nothing to look up in that case
// either, so the flag simply skips the walk.
//
this.canLookUpNameParts = byNamePart.TryGetAlternateLookup(out this.byNamePartLookup);
}
///
/// Every rule the index was built from, ordered by name.
///
public IReadOnlyList Rules { get; }
///
/// Rules which claim exactly the same names as another rule.
///
///
/// Found by comparing what the patterns say, which catches the case of two families claiming
/// one name outright. Two patterns which merely happen to overlap on some name cannot be found
/// this way -- deciding that in general is not a question about text any more. Those show up
/// when a name is actually resolved, as tied selectors, which is why the verification run
/// resolves the whole corpus instead of only reading the rules.
///
public IReadOnlyList Ambiguities { get; }
///
/// Builds an index over a set of rules.
///
/// The rules, in any order. The order they arrive in changes nothing.
/// The index.
public static ModelFamilyIndex Build(IEnumerable rules)
{
//
// Sorting by name, not by specificity: the comparison does the deciding, and a stable order
// is what makes two builds of the same rules produce the same answers, down to which rule
// is reported first in a conflict.
//
var ordered = rules.OrderBy(rule => rule.Description, StringComparer.Ordinal).ToArray();
var buckets = new Dictionary>(StringComparer.Ordinal);
var alwaysChecked = new List();
foreach (var rule in ordered)
{
var namePart = rule.Pattern.IndexKey();
if (namePart.IsEmpty)
{
alwaysChecked.Add(rule);
continue;
}
var key = namePart.ToString();
if (!buckets.TryGetValue(key, out var bucket))
buckets[key] = bucket = [];
bucket.Add(rule);
}
var byNamePart = buckets.ToFrozenDictionary(bucket => bucket.Key, bucket => bucket.Value.ToArray(), StringComparer.Ordinal);
return new(ordered, byNamePart, alwaysChecked.ToArray(), FindAmbiguities(ordered));
}
///
/// Says what is known about a model.
///
/// The model name.
/// Who serves the model.
/// Who built it, as far as anybody knows.
/// The profile, which is empty when no rule knows the name.
public ModelProfile Resolve(in ModelId id, LLMProviders provider, ModelVendor vendor) => this.Explain(id, provider, vendor).Profile;
///
/// Says what is known about a model, and which rules said it.
///
/// The model name.
/// Who serves the model.
/// Who built it, as far as anybody knows.
/// The profile together with the rules behind it.
public ModelResolution Explain(in ModelId id, LLMProviders provider, ModelVendor vendor)
{
if (id.IsEmpty)
return ModelResolution.NOTHING;
var match = new Match();
Consider(this.alwaysChecked, id, provider, vendor, ref match);
if (this.canLookUpNameParts)
foreach (var namePart in id.Segments)
if (this.byNamePartLookup.TryGetValue(namePart, out var candidates))
Consider(candidates, id, provider, vendor, ref match);
//
// Least specific first, so that the rule saying the most about this name has the last word.
// Sorting a list is not stable, so equally specific modifiers are ordered by name: applying
// them in a different order could otherwise produce a different profile on another machine.
//
match.Modifiers?.Sort(static (left, right) =>
{
var order = left.Specificity.CompareTo(right.Specificity);
return order is not 0 ? order : string.CompareOrdinal(left.Description, right.Description);
});
var profile = match.Selector?.Change.ApplyTo(ModelProfile.UNKNOWN) ?? ModelProfile.UNKNOWN;
if (match.Modifiers is not null)
foreach (var modifier in match.Modifiers)
profile = modifier.Change.ApplyTo(profile);
return new(profile, match.Selector, match.Modifiers ?? [], match.TiedSelectors ?? []);
}
private static void Consider(ModelRule[] candidates, in ModelId id, LLMProviders provider, ModelVendor vendor, ref Match match)
{
foreach (var rule in candidates)
{
if (!rule.Pattern.Matches(id, provider, vendor))
continue;
if (rule.Kind is ModelRuleKind.MODIFIER)
{
//
// A rule can be reached twice when a name repeats one of its parts. Applying a
// modifier twice would change nothing, but reporting it twice would read as if two
// rules had spoken.
//
match.Modifiers ??= [];
if (!match.Modifiers.Contains(rule))
match.Modifiers.Add(rule);
continue;
}
if (match.Selector is null)
{
match.Selector = rule;
continue;
}
if (ReferenceEquals(match.Selector, rule))
continue;
var order = rule.Specificity.CompareTo(match.Selector.Specificity);
if (order > 0)
{
match.Selector = rule;
match.TiedSelectors = null;
continue;
}
if (order < 0)
continue;
//
// Both rules claim the name with the same right, which the rules should not allow. The
// answer still has to be the same one on every machine and in every build, so the name
// of the rule decides rather than the order the rules arrived in.
//
var winner = string.CompareOrdinal(rule.Description, match.Selector.Description) < 0 ? rule : match.Selector;
var loser = ReferenceEquals(winner, rule) ? match.Selector : rule;
match.Selector = winner;
(match.TiedSelectors ??= []).Add(loser);
}
}
private static IReadOnlyList FindAmbiguities(IReadOnlyList rules)
{
var ambiguities = new List();
var claimed = new Dictionary(StringComparer.Ordinal);
foreach (var rule in rules)
{
if (rule.Kind is not ModelRuleKind.SELECTOR)
continue;
var signature = rule.Pattern.Signature();
if (claimed.TryGetValue(signature, out var other))
{
ambiguities.Add(new(other, rule, "Two selectors claim exactly the same model names."));
continue;
}
claimed[signature] = rule;
}
return ambiguities;
}
///
/// What the walk over the candidate rules has found so far.
///
private struct Match
{
public ModelRule? Selector;
public List? TiedSelectors;
public List? Modifiers;
}
}