Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions Frontmatter.Test/FuzzyRankingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Frontmatter.Test;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that candidate property names are chosen by similarity rather than by iteration order.
/// </summary>
/// <remarks>
/// Containment admits a candidate; it does not rank one. Before these matchers scored their
/// candidates, the winner was whichever plausible name <see cref="StandardOrder.PropertyNames"/>
/// happened to list first, so a more specific name lost to a shorter one that merely appeared
/// earlier.
/// </remarks>
[TestClass]
public class FuzzyRankingTests
{
private static Dictionary<string, object> Standardize(string key, string value)
{
string input = $"---{Environment.NewLine}" +
$"{key}: {value}{Environment.NewLine}" +
$"---{Environment.NewLine}" +
$"Content";

string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.Standard);
Dictionary<string, object>? extracted = Frontmatter.ExtractFrontmatter(result);

return extracted ?? throw new AssertFailedException(
$"Standardizing '{key}' produced no frontmatter to assert against.");
}

[TestMethod]
public void StandardizePropertyNames_CandidateSharesAPrefixWithAnEarlierProperty_PrefersTheCloserMatch()
{
// "subtitles_track" contains both "title" and "subtitle". "title" is listed first in
// StandardOrder.PropertyNames, so an unranked match returns it.
Dictionary<string, object> extracted = Standardize("subtitles_track", "en.vtt");

Assert.IsTrue(
extracted.ContainsKey("subtitle"),
$"Expected 'subtitles_track' to standardize to 'subtitle'; got '{string.Join(", ", extracted.Keys)}'.");
Assert.AreEqual("en.vtt", extracted["subtitle"]);
}

[TestMethod]
public void StandardizePropertyNames_CandidateContainsACompoundProperty_PrefersTheMoreSpecificMatch()
{
// "review_status_flag" contains both "status" and "review_status". "status" is listed
// first, so an unranked match returns the less specific of the two.
Dictionary<string, object> extracted = Standardize("review_status_flag", "approved");

Assert.IsTrue(
extracted.ContainsKey("review_status"),
$"Expected 'review_status_flag' to standardize to 'review_status'; got '{string.Join(", ", extracted.Keys)}'.");
Assert.AreEqual("approved", extracted["review_status"]);
}

[TestMethod]
public void Score_NeitherNameIsASubsequenceOfTheOther_ReportsNoScore()
{
Assert.AreEqual(int.MinValue, FuzzyRanking.Score("review_notes", "notes_reviewed"));
}

[TestMethod]
public void Score_IsSymmetric_SoAShorterCandidateStillScores()
{
// The callers' containment gates admit a candidate whether it contains the key or is
// contained by it, so a one-directional score would leave half of them unranked.
Assert.AreEqual(
FuzzyRanking.Score("subtitle", "subtitles_track"),
FuzzyRanking.Score("subtitles_track", "subtitle"));
}

[TestMethod]
public void Score_RanksTheCloserOfTwoContainedNamesHigher() =>
Assert.IsGreaterThan(
FuzzyRanking.Score("subtitles_track", "title"),
FuzzyRanking.Score("subtitles_track", "subtitle"));
}
55 changes: 55 additions & 0 deletions Frontmatter/FuzzyRanking.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Frontmatter;

using ktsu.FuzzySearch;

/// <summary>
/// Ranks candidate property names by approximate similarity, using <see cref="Fuzzy"/>.
/// </summary>
/// <remarks>
/// Both property-name matchers in this assembly select a candidate out of a set that a coarse
/// containment test has already admitted. Containment says only whether a candidate is plausible,
/// not how good it is, so without a score the winner is decided by iteration order -- which is why
/// <c>subtitles_track</c> used to standardize to <c>title</c> rather than <c>subtitle</c>.
/// <para>
/// <see cref="Fuzzy.Contains(System.ReadOnlySpan{char}, System.ReadOnlySpan{char}, out int)"/>
/// scores a match by rewarding consecutive characters, matches after a separator and matches on a
/// camelCase boundary, and by penalizing characters the pattern never matched. That is the ranking
/// both call sites were approximating by hand.
/// </para>
/// </remarks>
internal static class FuzzyRanking
{
/// <summary>
/// Scores how well two normalized property names match, ignoring which of them is longer.
/// </summary>
/// <param name="first">The first normalized property name.</param>
/// <param name="second">The second normalized property name.</param>
/// <returns>
/// The better of the two directional scores, or <see cref="int.MinValue"/> if neither name is a
/// subsequence of the other.
/// </returns>
/// <remarks>
/// Both directions are tried because the callers' containment gates are themselves
/// bidirectional: a candidate qualifies whether it contains the key or is contained by it.
/// Scoring only one direction would return no score at all for half of the candidates that the
/// gate admitted.
/// </remarks>
internal static int Score(string first, string second)
{
int best = int.MinValue;

if (Fuzzy.Contains(first.AsSpan(), second.AsSpan(), out int forward))
{
best = forward;
}

if (Fuzzy.Contains(second.AsSpan(), first.AsSpan(), out int reverse) && reverse > best)
{
best = reverse;
}

return best;
}
}
21 changes: 17 additions & 4 deletions Frontmatter/NameStandardizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,30 @@
return exactMatch;
}

// Try partial matches
// Try partial matches. Containment only says a standard property is plausible, so the
// candidates it admits are ranked by fuzzy similarity rather than returning whichever one
// StandardOrder.PropertyNames happens to list first. Ties keep the earlier property, so the
// standard order still decides when the scores cannot.
string? bestProperty = null;
int bestScore = int.MinValue;

foreach (string standardProperty in standardProperties)
{
string normalizedStandard = NormalizePropertyName(standardProperty);
if (normalizedKey.Contains(normalizedStandard) || normalizedStandard.Contains(normalizedKey))
if (!normalizedKey.Contains(normalizedStandard) && !normalizedStandard.Contains(normalizedKey))
{
continue;
}

int score = FuzzyRanking.Score(normalizedKey, normalizedStandard);
if (score > bestScore)
{
return standardProperty;
bestProperty = standardProperty;
bestScore = score;
}
}

return null;
return bestProperty;
}

private static string NormalizePropertyName(string key)
Expand All @@ -118,7 +131,7 @@

// Remove common prefixes
string[] prefixes = ["page_", "post_", "meta_", "custom_", "user_", "site_"];
foreach (string prefix in prefixes)

Check warning on line 134 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 134 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 134 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 134 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 134 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 134 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (key.StartsWith(prefix))
{
Expand All @@ -129,7 +142,7 @@

// Remove common suffixes
string[] suffixes = ["_value", "_text", "_data", "_info", "_meta", "_field"];
foreach (string suffix in suffixes)

Check warning on line 145 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 145 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 145 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 145 in Frontmatter/NameStandardizer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (key.EndsWith(suffix))
{
Expand Down
Loading