Match lift-ranges range-element on guid rather than id - #394
Match lift-ranges range-element on guid rather than id#394imnasnainaec wants to merge 9 commits into
Conversation
A range-element's id is the possibility's own name, so it moves whenever that name is renamed or respelled. Matching only on the id turns such a move into a deletion plus an addition: the other user's edits to the same element raise a spurious removed-vs-edited conflict, and the merge output can carry two elements for one possibility. Prefer the guid FLEx writes, falling back to the id when either element lacks a guid, since LIFT makes the guid optional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The finder returned the first candidate in document order, so a sibling that merely still carried the old id could beat the element that actually shared the guid. Consult the guid index first, and fall back to the id only against elements that name no guid of their own. Index each parent's children once instead of rescanning them per child, as the single-key finder already does; a range can hold a couple of thousand elements. Duplicate ids are kept rather than rejected, since two elements may share an id and still differ by guid. Also report both keys in the ambiguity warning, since either can be what formed the group, and build the test's two spellings by normalizing so they cannot be flattened by an editor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Letting an element that names no guid match one that does made matching non-transitive, so a set holding both could give one element two partners: the same incoming edit was merged into two siblings, raising a conflict nobody caused and, where our side had not also changed, silently applying someone's edit to a different object. Match guid-bearing elements only against guid-bearing ones, and bare elements only against bare ones. That makes matching an equivalence relation, at the price of reading a file that starts naming guids as a deletion plus an addition, once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
State on IFindNodeToMerge that discarding a result outside acceptableTargets is the caller's job, since an indexed finder answers from its index without consulting the set, and note that a ParentIndex is built once, so a parent whose children change between searches is still answered from the children it first held. Spell out what matching guid-bearing and bare elements separately costs: where one revision names the permanent key and another does not, the element reads as a deletion plus an addition, so writers that disagree over whether to name it pay that on every merge between them rather than once. DroppingTheGuidReadsAsADeletionPlusAnAddition pins that shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Which key an element is matched on is decided by whether it names the permanent one, so that key alone is what its ambiguous siblings share. The warning named both, accusing a key the match never consulted and which for two elements sharing a permanent key may well differ between them -- as it does for a possibility left standing twice under two spellings of its name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Revisit this comment with preferred key and fallback key language instead of permanently and ordinary. |
The class summary described the two keys as an attribute that identifies an element permanently (a guid) and an ordinary key, and the comments followed with permanent, respellable and bare. Three problems: the guid belongs to the LIFT wiring rather than to a generic finder that takes any two attribute names; permanence is a claim the class neither enforces nor checks; and both keys are read as optional attributes, so calling only one of them optional invents an asymmetry that is not there. Use the vocabulary the constructor, the fields and the index already use, which the ParentIndex comment alone was doing. Also drop "falling back to" from the summary, which outlived the change that made the two groups match separately: an element that names the preferred key and finds no partner is never retried against the fallback key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Key" was doing duty for the attribute name, the attribute value and the dictionary key, and "name" for the element name, the attribute name and the possibility's own name, which is the id value. Some sentences only parsed under one reading: "two elements can share the fallback key" is vacuous read as attribute names, since every range-element has an @id. Follow the convention the shipped warning text already uses -- "key attribute" plus the quoted name for the name, "value" for the value -- say "carries a value" rather than "names" for presence, write @Guid and @id at the LIFT sites, and say that ParentIndex is keyed on (element name, attribute value), which the tuple built three lines below it did not say. That also lets the summary state a rule it had left out: both keys are read with GetOptionalAttributeString and every test is string.IsNullOrEmpty, so an attribute present but empty counts as absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four identifiers said "key" for three different things. IndexFirstOnly took a "key" that was an attribute value and built an "indexKey" that was the index tuple, three lines apart; extract IndexKey so the tuple is named once and built in one place rather than by hand at each of the three lookups. ByFallbackKeyAlone meant "keyed on the fallback value, among the children that carry that alone" but parses as "keyed on the fallback value alone", which is vacuously true of a dictionary and says nothing about the partition it exists to hold; it needed its doc comment to be read correctly, so name it ByFallbackKeyWherePreferredAbsent. IsMatch's bare preferredKey/fallbackKey are the sought node's values while candidatePreferredKey is the other side's, an asymmetry resting on one prefix; prefix both sides. In the tests, kDecomposedName/kComposedName hold the two @id values a possibility is exported under, so "Name" was the possibility-name sense next to element and attribute names -- call them ids, as the assertions do. In RangesWithPartOfSpeech, a null guid silently means "omit @Guid" rather than "empty guid", which the parameter name now says. No behavior change; 541 pass and the same 13 fail on Mercurial not being configured here, as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The class header had grown to 21 lines of prose for a 150-line class, most of it the commit messages that produced it. Cut what is recorded elsewhere and keep what a maintainer must read before reusing the finder. Gone: the summary's two restatements of the partition rule that opens the second paragraph; the counterfactual showing why a mixed set would pair an element twice (847e0ff); the once-versus-every-merge cost stated three times over (f94c6d0, and DroppingTheGuidReadsAsADeletionPlusAnAddition); the consequences of matching on a changeable value (the CHANGELOG entry and RespelledIdMergesAsAnEditWhenTheGuidIsUnchanged); and the warning comment's account of why the unmatched attribute's values may differ, which the test's own comment now carries. Kept deliberately, being the parts the last two commits bought: "never both for the same element" in the summary, since "fallback" alone still invites the sequential reading that the class name encourages; "preserve the value it finds" rather than "one it finds"; the (element name, attribute value) tuple on ParentIndex; "the attribute whose values are the same"; and the rule that an empty attribute counts as absent, moved into the paragraph on the partition rather than dropped with the rest of the summary. Also kept whole: IFindNodeToMerge.GetNodeToMerge, whose four lines are each an obligation, and which replaced a contract that was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jasonleenaylor
left a comment
There was a problem hiding this comment.
@jasonleenaylor reviewed 1 file and made 2 comments.
Reviewable status: 1 of 4 files reviewed, all discussions resolved.
src/LibChorus/merge/xml/generic/FindNodeToMerge.cs line 309 at r4 (raw file):
/// Unlike a finder with a single key attribute, two children legitimately hold the same /// fallback value here, since a preferred value can still tell them apart. Keep the /// first, matching how the merger resolves siblings it cannot tell apart.
This comment could use a bit more work. It mentions fallback, but no arguments or code carries that concept here.
I would replace it all with something like:
Will add the key to node index for the first node matching the key. A second call will skip the node which matches how the merger handles siblings that look like duplicates.
src/LibChorus/merge/xml/generic/FindNodeToMerge.cs line 319 at r4 (raw file):
/// <summary> /// A ParentIndex entry is identified by the element name as well as the attribute value,
This ties the method description to a place where the method is being used, it should only describe its own purpose and the 'why' should probably not be specific to any particular calling code.
Quick Summary
range-elementacross revisions by its@id, comparing ordinally andnormalizing nothing.
recapitalized, or respelled. Chorus then sees not "an attribute was edited" but "one
element deleted, a different element added."
DoDeletionsraises a
RemovedVsEditedElementConflict— a conflict note the users did nothing toearn.
<range-element>with the same@guid, idsdiffering only in the respelling, because our element is an unmatched addition while
theirs was kept by the conflict.
<label>,<abbrev>,<description>andtraits travel with it rather than merging.
guidFLEx writes on every range-element, and on theidonlyamong the elements that name no guid, since LIFT makes the guid optional.
What prompted it
FieldWorks sillsdev/FieldWorks#1063
(LT-22697) fixes a LIFT export defect: several
.lift-rangeswrites emitted FLEx'sin-memory NFD strings while the rest of the export, and the companion
.lift, are NFC.The fix normalizes the range-element ids, so every affected id is respelled once — and,
because the id is derived from the name at export time rather than stored, respelled on
every export by any client that has not yet upgraded. The exporter fix therefore lands on
exactly the weakness above, repeatedly, for as long as a project has a mixed set of FLEx
versions.
Nothing here is specific to normalization. An ordinary rename of a part of speech has always
had the same shape and is fixed by the same change.
The change
FindByPreferredKeyAttributeA new finder beside the others in
FindNodeToMerge.cs, constructed with a preferred key and afallback key.
never against each other. Two elements naming a guid are partners when the guid agrees,
whatever their ids say; two naming none are partners when the id agrees, which is what this
handler did before. Element names must agree too, matching the
(name, key)tupleFindByKeyAttributealready uses. Unlike that single-key finder, a duplicate id is keptrather than treated as a programming error, since two elements may legitimately share an id
and still be told apart by their guid; the first is indexed, matching how the merger resolves
siblings it cannot distinguish.
a parent holding both could give a single element two partners — Chorus would merge one
incoming edit into two siblings, raising a conflict nobody caused and, where our side had not
also changed, quietly applying someone's edit to a different object. Keeping them apart makes
matching an equivalence relation, so nothing can be paired twice.
does not:
RemovedVsEditedElementConflictif the other revision also edited it — the shape this PR removes for respelling, reached
instead by dropping a guid.
them. FLEx writes a guid on every range-element, so the fallback is for files whose writers
omit it consistently, not for a mixture.
GetNodeToMergeresolves through the guidindex, so a sibling that merely still carries the old id cannot win by appearing earlier in
the file.
children that name no guid — rather than rescanned for every child, the same reason
FindByKeyAttributecarries an index: a semantic-domain range runs to a couple of thousandelements and is walked for ours, theirs, ancestor and the ambiguity pass. An index is built
when its parent is first searched and never revisited, which the callers here satisfy and
which the code now says out loud.
IFindMatchingNodesToMerge, not justIFindNodeToMerge, soXmlMergeService.RemoveAmbiguousChildrenstill applies to range-elements. That also means aranges file already carrying the duplicated pair, from a merge done before this change,
has the two collapsed back into one — with a merge warning — the next time it is merged.
FindByKeyAttribute.GetMatchingNodescompares only the key attribute, so differently-named siblings sharing an id could be judged
ambiguous and one of them dropped. This finder requires the element names to agree, which also
aligns
GetMatchingNodeswith the tupleGetNodeToMergeresolves on.decides which key it was matched on, so that key alone is what its ambiguous siblings share.
Naming both would accuse a key the match never consulted, and which for two elements sharing a
guid may well differ between them. The text follows the phrasing
FindByKeyAttributealreadyuses:
The key attribute 'guid' has values that are the same '…'.finder takes any two attribute names, so naming the guid in its summary put the LIFT case into
merge/xml/generic; calling that key permanent claimed something the class neither enforces norchecks; and both keys are read with
GetOptionalAttributeString, so calling only one of themoptional invented an asymmetry that is not there. The wiring comment and the tests keep the
guid/id language, which is where the concrete keys belong. The summary also no longer says
"falling back", which had outlived the separate-sets rule above: an element that names a guid and
finds no partner is not retried against the
id.IFindNodeToMergeThe summary promised that a non-null result is a value in
acceptableTargets— a contract noindexed implementation has ever honored, since
FindByKeyAttributeand the new finder bothanswer from their index without consulting the set. It now states what implementations do
guarantee, a child of
parentToSearchInor null, and puts the filtering obligation on callersthat pass a strict subset, which is what
MergeChildrenMethod.FindMatchingNodehas always done.Wiring
range-elementis switched toFindByPreferredKeyAttribute("guid", "id"). Note thatAddLiftRangeElementStrategiesis registered by both the ranges handler and the main LIFThandler, so this also governs any
range-elementappearing inside a.liftheader's<ranges>. That is intended — the identity argument is the same either way — but it is a widersurface than the
.lift-rangesfile alone, and only the.lift-rangespath has test coveragehere.
Deliberately not in this PR
<range>itself. Itsidis a stable identifier (grammatical-info,morph-type)rather than user data, and
XmlMergeService.Do3WayMergesplits the file into records byrange/@idoutside the ElementStrategy system. Changing the element strategy alone would putthe two out of step.
<trait>. Keyed onname+value, so a respelled value is a delete plus an add theretoo, including the
feature-settrait whose value is a composed feature-structure name.@idis optional on a trait and FLEx does not write it.OptionalKeyAttrFindercall sites. Same prefer-then-fall-back shape, and theobvious thing to reuse, but it implements only
IFindNodeToMerge:IFindMatchingNodesToMergewould change behavior fornote,field,relation,etymologyandexamplein the LIFT handler — a wider change than this defectjustifies, and worth doing on its own.
acceptableTargetsinside the finders. The documentation is brought in linewith the implementations rather than the reverse; making every finder honor the set would
change matching for callers that pass a strict subset, which none of the current ones do.
<range>ends up with a repeatedrange-element/@id. Defensible,but it is a new diagnostic needing a post-merge pass over the file rather than anything the
finder can see, and the trade-off below covers what such a file costs its consumers.
One behavior trade-off, for reviewers to weigh
Two range-elements that share an id but hold different guids are now kept apart, where before
they were merged into one and a guid was lost;
RemoveAmbiguousChildrenno longer treats such apair as ambiguous either. They are two objects, so separate is right — but a merged ranges file
can now carry a duplicate
range-element/@id, which is what<grammatical-info value="…"/>andtraits are resolved against. What its two consumers do with such a file:
LiftSorter.SortRangekeys its sorted collection throughGetUniqueKey, which appends a numeric suffix rather than throwing on a repeated id. Runagainst the
SIL.Lift18.0.0-beta0032 this repo resolves, a ranges file holding two same-idelements sorts cleanly and keeps both.
sillsdev/FieldWorks@main):(
m_dictPos[id] = posinProcessPartOfSpeech) or guarded byContainsKey(
AddToPossibilityMap); nothing callsAddon a bare range-element id.GetPossibilityForGuidIfExisting), then name andabbreviation (
FindMatchingPossibility), then created. Two same-id elements whose guids theproject does not know collapse into one possibility if their labels and abbrevs agree, and
stand as two if they differ: the same dedupe-by-label the merge used to do, moved inside the
project where a user can see and fix it.
m_dictPos(
FindOrCreatePartOfSpeech), which for a duplicated id holds the element processed last, sosuch a reference attaches to one of the two deterministically.
Older FLEx versions and WeSay's own
ILexiconMergerimplementation have not been checked. Thecost is bounded either way: a duplicate id is only reachable where two possibilities genuinely
share a name, and it costs a name-based reference landing on one of them, where the old behavior
cost an object its identity outright.
RangeElementsWithDifferentGuidsAreNotMatchedpins the newshape so it can be argued about rather than discovered.
Testing
Six tests in
LiftRangesFileHandlerTests:RespelledIdMergesAsAnEditWhenTheGuidIsUnchanged— the LT-22697 scenario. We respell the id;they edit the abbreviation without upgrading. Asserts one element, our normalized id, and
their edit surviving, with no conflict.
GuidMatchIsPreferredOverAnEarlierSiblingMatchingOnTheOldId— a guid-less sibling carryingthe old id sits ahead of the respelled element. Asserts their edit lands on the element
sharing the guid, the guid-less sibling keeps its own content, and no conflict is raised.
RangeElementWithoutAGuidStillMergesOnItsId— the fallback, for writers that omit guids.DroppingTheGuidReadsAsADeletionPlusAnAddition— the cost above, where a writer omits theguid the ancestor names and the other side edits the element.
RangeElementsWithDifferentGuidsAreNotMatched— the trade-off above.RangeElementsDuplicatedByAnEarlierMergeCollapseToOne— the recovery path for repos alreadycarrying the duplicate, asserting also that the warning names the shared guid rather than
blaming the ids, which differ.
The two Unicode spellings are built with
Normalize(NormalizationForm.FormD/FormC)rather thanwritten as literals, since the forms are indistinguishable in source and an editor that
normalizes on save would quietly turn the test into a tautology.
The same 13 fail either way — all
Mercurial location has not been configuredin thisenvironment, none on a code path this touches. net462 and the full suite have not been run
locally; leaving this as a draft for CI.
This change is