Skip to content

Maximum merging depends on key order: name_of_author + author_name merge in one order and stay separate in the other, because each key is scored against itself #167

Description

@matt-edmondson

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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions