What's wrong
PropertyMerger.FindSemanticCanonicalName (Frontmatter/PropertyMerger.cs:252-279) is the step that FrontmatterMergeStrategy.Maximum adds on top of Aggressive. It scores the key against every entry of existingKeys and picks the highest score. existingKeys is the full key list (MergeSimilarProperties, :29-35), and there is no existingKey != key filter, so it includes the key itself (:265-276).
A key scored against itself always gets the highest possible word-overlap score (CalculateWordMatchScore, :283-303: 2 points per word, plus cross-word containment hits). That has two effects:
- A key maps to another key only when the other key scores at least as high as the key's own name. For a key with fewer words that can only happen on a tie. A tie goes to whichever key comes first in
existingKeys, because OrderByDescending is stable and the code takes .FirstOrDefault(). So the result depends on the order of keys in the document.
- The mapping is not symmetric. The key with more words always maps to itself. The shorter key maps to it only when the longer key comes first.
Reproduction (built against HEAD 6c9029c, net10.0)
string Run(string fm) => Frontmatter.CombineFrontmatter(
"---\n" + fm + "\n---\nBody\n",
FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.Maximum);
Run("name_of_author: [Bob]\nauthor_name: [Alice]");
// name_of_author:
// - Bob
// - Alice
Run("author_name: [Alice]\nname_of_author: [Bob]");
// author_name:
// - Alice
// name_of_author:
// - Bob
These are the same properties with the same values. They merge in one order and stay separate in the other. The scores explain it:
author_name = [author, name] scores 4 against itself and 4 against name_of_author. That is a tie, so the first key wins.
name_of_author = [name, of, author] scores 6 against itself and 4 against author_name, so it always maps to itself.
The same self-inclusion makes the mapping non-transitive in general. Key A can pick B while B picks C. MergeSimilarProperties then puts A alone under the name B, while B's value goes under C. This is the rename-onto-another-key pattern that #131 addresses for the basic (exact-normalized) pass. Open PR #165 only changes FindBasicCanonicalName and adds an early return for equivalence-class members. It does not touch the word-score selection.
Why it matters
Maximum is documented as "semantic analysis, fuzzy matching, and all available strategies" (Enums.cs). Yet reordering keys in a document, or stacking blocks in a different order, changes which properties get merged. Key order changes all the time: formatters reorder keys, and so does FrontmatterOrder.Sorted on an earlier pass. As a result, running CombineFrontmatter on its own output after a key reorder can give a different result.
Suggested fix
Acceptance criteria
- For the two inputs above,
Maximum gives the same set of properties: either merged in both cases or separate in both.
- A test permutes the key order of a multi-key
Maximum input and checks that the same keys and values come out, ignoring order.
- No key's value ends up under another key's name unless the two keys were merged into one group.
What's wrong
PropertyMerger.FindSemanticCanonicalName(Frontmatter/PropertyMerger.cs:252-279) is the step thatFrontmatterMergeStrategy.Maximumadds on top of Aggressive. It scores the key against every entry ofexistingKeysand picks the highest score.existingKeysis the full key list (MergeSimilarProperties,:29-35), and there is noexistingKey != keyfilter, so it includes the key itself (:265-276).A key scored against itself always gets the highest possible word-overlap score (
CalculateWordMatchScore,:283-303: 2 points per word, plus cross-word containment hits). That has two effects:existingKeys, becauseOrderByDescendingis stable and the code takes.FirstOrDefault(). So the result depends on the order of keys in the document.Reproduction (built against HEAD 6c9029c, net10.0)
These are the same properties with the same values. They merge in one order and stay separate in the other. The scores explain it:
author_name= [author, name] scores 4 against itself and 4 againstname_of_author. That is a tie, so the first key wins.name_of_author= [name, of, author] scores 6 against itself and 4 againstauthor_name, so it always maps to itself.The same self-inclusion makes the mapping non-transitive in general. Key A can pick B while B picks C.
MergeSimilarPropertiesthen puts A alone under the nameB, while B's value goes underC. This is the rename-onto-another-key pattern that #131 addresses for the basic (exact-normalized) pass. Open PR #165 only changesFindBasicCanonicalNameand adds an early return for equivalence-class members. It does not touch the word-score selection.Why it matters
Maximumis documented as "semantic analysis, fuzzy matching, and all available strategies" (Enums.cs). Yet reordering keys in a document, or stacking blocks in a different order, changes which properties get merged. Key order changes all the time: formatters reorder keys, and so doesFrontmatterOrder.Sortedon an earlier pass. As a result, runningCombineFrontmatteron its own output after a key reorder can give a different result.Suggested fix
FindSemanticCanonicalName, exclude the key itself from the candidate set (existingKeys.Where(k => k != key)). Require a minimum score for a match, instead of> 0measured against a self score.PreferredNamerule that PR Merge keys that normalize alike under one name instead of swapping them [patch] #165 introduces: known mapping first, then shortest, then ordinal.Fuzzy.Contains, but not the self-comparison or the order dependence.Acceptance criteria
Maximumgives the same set of properties: either merged in both cases or separate in both.Maximuminput and checks that the same keys and values come out, ignoring order.