Skip to content

Commit b95da37

Browse files
claudemyieye
authored andcommitted
Add HomographNumber support to MiniLCM
Add HomographNumber (int, 0 = unset) to the Entry model with full round-trip support through CRDT, FwData bridge, and sync. Key changes: - Entry model: add HomographNumber property with Copy() support - CreateEntryChange: persist HomographNumber in CRDT changes - CrdtMiniLcmApi: auto-assign homograph numbers on entry creation when HomographNumber is 0, respecting SecondaryOrder scoping. Updates existing lone entries from 0→1 when a second homograph appears. - FwDataMiniLcmApi: read HomographNumber from ILexEntry, set on create - UpdateEntryProxy: bidirectional HomographNumber sync to LibLCM - EntrySync: include HomographNumber in diff/patch operations - Sorting: uncomment HomographNumber in CRDT sort and search queries - Tests: uncomment sorting tests with HomographNumber, add auto- assignment tests, add sync test verifying LibLCM corrects numbers after entry deletion via two sync cycles https://claude.ai/code/session_01FJj2v135u6KdgVxoK4tRp2
1 parent 87c5dad commit b95da37

11 files changed

Lines changed: 265 additions & 41 deletions

File tree

backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,7 @@ private Entry FromLexEntry(ILexEntry entry)
660660
CitationForm = FromLcmMultiString(entry.CitationForm),
661661
LiteralMeaning = FromLcmMultiString(entry.LiteralMeaning),
662662
MorphType = LcmHelpers.FromLcmMorphType(entry.PrimaryMorphType), // TODO: Decide what to do about entries with *mixed* morph types
663+
HomographNumber = entry.HomographNumber,
663664
Senses = [.. entry.AllSenses.Select(FromLexSense)],
664665
ComplexFormTypes = ToComplexFormTypes(entry),
665666
Components = [.. ToComplexFormComponents(entry)],
@@ -1001,6 +1002,7 @@ public async Task<Entry> CreateEntry(Entry entry, CreateEntryOptions? options =
10011002
UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm);
10021003
UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning);
10031004
UpdateLcmMultiString(lexEntry.Comment, entry.Note);
1005+
lexEntry.HomographNumber = entry.HomographNumber;
10041006

10051007
foreach (var sense in entry.Senses)
10061008
{

backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ public override RichMultiString Note
8282
get => new UpdateRichMultiStringProxy(_lcmEntry.Comment, _lexboxLcmApi);
8383
set => throw new NotImplementedException();
8484
}
85+
86+
public override int HomographNumber
87+
{
88+
get => _lcmEntry.HomographNumber;
89+
set => _lcmEntry.HomographNumber = value;
90+
}
8591
}
8692

8793
public class UpdateMultiStringProxy(ITsMultiString multiString, FwDataMiniLcmApi lexboxLcmApi) : MultiString

backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,4 +678,56 @@ public async Task CanCreateAComplexFormTypeAndSyncsIt()
678678

679679
_fixture.FwDataApi.GetComplexFormTypes().ToBlockingEnumerable().Should().ContainEquivalentOf(complexFormEntry);
680680
}
681+
682+
[Fact]
683+
[Trait("Category", "Integration")]
684+
public async Task HomographNumbers_CorrectedByFwDataAfterTwoSyncs()
685+
{
686+
var crdtApi = _fixture.CrdtApi;
687+
var fwdataApi = _fixture.FwDataApi;
688+
689+
// Import and establish sync baseline
690+
await _syncService.Import(crdtApi, fwdataApi);
691+
var projectSnapshot = await _fixture.RegenerateAndGetSnapshot();
692+
693+
// Create two entries in CRDT with the same headword. Auto-assignment gives them (1, 2).
694+
var entry1 = await crdtApi.CreateEntry(new Entry
695+
{
696+
LexemeForm = { { "en", "homographtest" } },
697+
});
698+
var entry2 = await crdtApi.CreateEntry(new Entry
699+
{
700+
LexemeForm = { { "en", "homographtest" } },
701+
});
702+
entry1 = await crdtApi.GetEntry(entry1.Id) ?? throw new NullReferenceException();
703+
entry1.HomographNumber.Should().Be(1);
704+
entry2.HomographNumber.Should().Be(2);
705+
706+
// Sync to FwData so both sides have the entries
707+
await _syncService.Sync(crdtApi, fwdataApi, projectSnapshot);
708+
projectSnapshot = await _fixture.RegenerateAndGetSnapshot();
709+
710+
// Delete entry1 in CRDT
711+
await crdtApi.DeleteEntry(entry1.Id);
712+
713+
// After deleting, entry2 should still have HomographNumber 2 in CRDT
714+
// (we don't recalculate on delete in CRDT — that's intentional for now)
715+
var entry2AfterDelete = await crdtApi.GetEntry(entry2.Id) ?? throw new NullReferenceException();
716+
// If this assertion fails, it means CRDT now adjusts homograph numbers on delete.
717+
// In that case, remove this test and add a test verifying CRDT handles it correctly.
718+
entry2AfterDelete.HomographNumber.Should().Be(2,
719+
"CRDT should not recalculate homograph numbers on delete — that's LibLCM's job");
720+
721+
// First sync: propagates the delete to FwData. LibLCM's CorrectHomographNumbers should
722+
// update entry2 from 2→0 (since it's now the only entry with that headword).
723+
await _syncService.Sync(crdtApi, fwdataApi, projectSnapshot);
724+
projectSnapshot = await _fixture.RegenerateAndGetSnapshot();
725+
726+
// Second sync: FwData's corrected HomographNumber (0) should sync back to CRDT
727+
await _syncService.Sync(crdtApi, fwdataApi, projectSnapshot);
728+
729+
var entry2Final = await crdtApi.GetEntry(entry2.Id) ?? throw new NullReferenceException();
730+
entry2Final.HomographNumber.Should().Be(0,
731+
"after 2 syncs, LibLCM should have corrected HomographNumber to 0 (sole entry with this headword)");
732+
}
681733
}

backend/FwLite/LcmCrdt/Changes/CreateEntryChange.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public CreateEntryChange(Entry entry) : base(entry.Id == Guid.Empty ? Guid.NewGu
1717
LiteralMeaning = entry.LiteralMeaning;
1818
Note = entry.Note;
1919
MorphType = entry.MorphType;
20+
HomographNumber = entry.HomographNumber;
2021
}
2122

2223
[JsonConstructor]
@@ -34,6 +35,8 @@ private CreateEntryChange(Guid entityId) : base(entityId)
3435

3536
public MorphTypeKind? MorphType { get; set; }
3637

38+
public int HomographNumber { get; set; }
39+
3740
public override ValueTask<Entry> NewEntity(Commit commit, IChangeContext context)
3841
{
3942
return new(new Entry
@@ -44,6 +47,7 @@ public override ValueTask<Entry> NewEntity(Commit commit, IChangeContext context
4447
LiteralMeaning = LiteralMeaning ?? new(),
4548
Note = Note ?? new(),
4649
MorphType = MorphType ?? MiniLcm.Models.MorphTypeKind.Stem,
50+
HomographNumber = HomographNumber,
4751
});
4852
}
4953
}

backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,10 @@ public async Task<Entry> CreateEntry(Entry entry, CreateEntryOptions? options =
515515
{
516516
options ??= CreateEntryOptions.Everything;
517517
await using var repo = await repoFactory.CreateRepoAsync();
518+
if (entry.HomographNumber == 0)
519+
{
520+
await AssignHomographNumber(entry, repo);
521+
}
518522
await AddChanges([
519523
new CreateEntryChange(entry),
520524
..await entry.Senses.ToAsyncEnumerable()
@@ -619,6 +623,54 @@ private async ValueTask<bool> IsEntryDeleted(Guid id)
619623
return !await repo.Entries.AnyAsyncEF(e => e.Id == id);
620624
}
621625

626+
/// <summary>
627+
/// When creating a new entry whose HomographNumber is 0, find entries in the same
628+
/// "homograph scope" (same headword + SecondaryOrder) and assign the next number.
629+
/// If a lone existing entry has HomographNumber 0, promote it to 1.
630+
/// </summary>
631+
private async Task AssignHomographNumber(Entry entry, MiniLcmRepository repo)
632+
{
633+
var defaultVernacularWs = await repo.GetWritingSystem(new WritingSystemId("default"), WritingSystemType.Vernacular);
634+
if (defaultVernacularWs is null) return;
635+
636+
var wsId = defaultVernacularWs.WsId;
637+
var newHeadword = entry.Headword(wsId);
638+
if (string.IsNullOrEmpty(newHeadword)) return;
639+
640+
// Single DB query: join MorphTypes for SecondaryOrder filtering (same pattern as Sorting.cs)
641+
var morphTypes = repo.MorphTypes.ToLinqToDB();
642+
var stemOrder = morphTypes.Where(m => m.Kind == MorphTypeKind.Stem).Select(m => m.SecondaryOrder);
643+
var newSecondaryOrder = morphTypes
644+
.Where(m => m.Kind == entry.MorphType)
645+
.Select(m => (int?)m.SecondaryOrder).FirstOrDefault()
646+
?? stemOrder.FirstOrDefault();
647+
648+
var matchingEntries = await (
649+
from e in repo.Entries
650+
where e.Id != entry.Id && e.Headword(wsId) == newHeadword
651+
let so = morphTypes.Where(m => m.Kind == e.MorphType)
652+
.Select(m => (int?)m.SecondaryOrder).FirstOrDefault()
653+
?? stemOrder.FirstOrDefault()
654+
where so == newSecondaryOrder
655+
select new { e.Id, e.HomographNumber }
656+
).ToListAsyncLinqToDB();
657+
658+
if (matchingEntries.Count == 0) return;
659+
660+
var maxHomograph = matchingEntries.Max(e => e.HomographNumber);
661+
662+
// If there's exactly one existing entry with HomographNumber 0, update it to 1
663+
if (matchingEntries.Count == 1 && matchingEntries[0].HomographNumber == 0)
664+
{
665+
var patchDoc = new SystemTextJsonPatch.JsonPatchDocument<Entry>();
666+
patchDoc.Replace(e => e.HomographNumber, 1);
667+
await AddChanges(patchDoc.ToChanges(matchingEntries[0].Id));
668+
maxHomograph = 1;
669+
}
670+
671+
entry.HomographNumber = maxHomograph + 1;
672+
}
673+
622674
public async Task<Entry> UpdateEntry(Guid id,
623675
UpdateObjectInput<Entry> update)
624676
{

backend/FwLite/LcmCrdt/Data/Sorting.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ from entry in entries
1515
entry.Headword(order.WritingSystem).CollateUnicode(order.WritingSystem),
1616
morphTypes.Where(m => m.Kind == entry.MorphType)
1717
.Select(m => (int?)m.SecondaryOrder).FirstOrDefault() ?? stemOrder.FirstOrDefault(),
18-
// entry.HomographNumber,
18+
entry.HomographNumber,
1919
entry.Id
2020
select entry;
2121
}
@@ -27,7 +27,7 @@ from entry in entries
2727
entry.Headword(order.WritingSystem).CollateUnicode(order.WritingSystem) descending,
2828
(morphTypes.Where(m => m.Kind == entry.MorphType)
2929
.Select(m => (int?)m.SecondaryOrder).FirstOrDefault() ?? stemOrder.FirstOrDefault()) descending,
30-
// entry.HomographNumber descending,
30+
entry.HomographNumber descending,
3131
entry.Id descending
3232
select entry;
3333
}
@@ -53,7 +53,7 @@ from mt in mtGroup.DefaultIfEmpty()
5353
e.Headword(order.WritingSystem).Length,
5454
e.Headword(order.WritingSystem),
5555
mt != null ? mt.SecondaryOrder : stemOrder.FirstOrDefault(),
56-
// e.HomographNumber,
56+
e.HomographNumber,
5757
e.Id
5858
select e;
5959
}
@@ -69,7 +69,7 @@ from mt in mtGroup.DefaultIfEmpty()
6969
e.Headword(order.WritingSystem).Length descending,
7070
e.Headword(order.WritingSystem) descending,
7171
(mt != null ? mt.SecondaryOrder : stemOrder.FirstOrDefault()) descending,
72-
// e.HomographNumber descending,
72+
e.HomographNumber descending,
7373
e.Id descending
7474
select e;
7575
}

backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public IQueryable<Entry> FilterAndRank(IQueryable<Entry> queryable,
5757
.OrderBy(mt => mt.Kind == MorphTypeKind.Stem ? 1 : 0) // stem is the fallback, so it should come last
5858
.Select(mt => mt.SecondaryOrder).FirstOrDefault()
5959
: int.MaxValue)
60-
// .ThenBy(t => t.Entry.HomographNumber)
60+
.ThenBy(t => t.Entry.HomographNumber)
6161
.ThenBy(t => t.Entry.Id);
6262

6363
return ordered.Select(t => t.Entry);

backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,70 @@ public async Task CanCreate_WithComplexFormTypesProperty()
131131
entry.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id);
132132
}
133133

134+
[Fact]
135+
public async Task CreateEntry_AutoAssignsHomographNumber_WhenDuplicateHeadword()
136+
{
137+
var entry1 = await Api.CreateEntry(new() { LexemeForm = { { "en", "homograph" } } });
138+
entry1.HomographNumber.Should().Be(0, "single entry should have HomographNumber 0");
139+
140+
var entry2 = await Api.CreateEntry(new() { LexemeForm = { { "en", "homograph" } } });
141+
entry2.HomographNumber.Should().Be(2, "second entry should get HomographNumber 2");
142+
143+
// Re-read entry1 to verify it was updated from 0 to 1
144+
entry1 = await Api.GetEntry(entry1.Id) ?? throw new NullReferenceException();
145+
entry1.HomographNumber.Should().Be(1, "first entry should be updated to HomographNumber 1");
146+
}
147+
148+
[Fact]
149+
public async Task CreateEntry_RespectsExplicitHomographNumber()
150+
{
151+
var entry1 = await Api.CreateEntry(new() { LexemeForm = { { "en", "explicit" } }, HomographNumber = 5 });
152+
entry1.HomographNumber.Should().Be(5, "explicit HomographNumber should be preserved");
153+
}
154+
155+
[Fact]
156+
public async Task CreateEntry_DifferentSecondaryOrder_DoesNotShareHomographNumbers()
157+
{
158+
// Create MorphTypes with different SecondaryOrders
159+
await Api.CreateMorphType(new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 });
160+
await Api.CreateMorphType(new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 });
161+
162+
var rootEntry = await Api.CreateEntry(new() { LexemeForm = { { "en", "morphtest" } }, MorphType = MorphTypeKind.Root });
163+
rootEntry.HomographNumber.Should().Be(0, "lone Root entry should have HomographNumber 0");
164+
165+
var boundRootEntry = await Api.CreateEntry(new() { LexemeForm = { { "en", "morphtest" } }, MorphType = MorphTypeKind.BoundRoot });
166+
boundRootEntry.HomographNumber.Should().Be(0, "BoundRoot with different SecondaryOrder should have HomographNumber 0");
167+
}
168+
169+
[Fact]
170+
public async Task CreateEntry_AutoAssignsHomographNumber_WithCitationForm()
171+
{
172+
var entry1 = await Api.CreateEntry(new() { LexemeForm = { { "en", "cfLexeme1" } }, CitationForm = { { "en", "cfHomograph" } } });
173+
entry1.HomographNumber.Should().Be(0, "single entry should have HomographNumber 0");
174+
175+
var entry2 = await Api.CreateEntry(new() { LexemeForm = { { "en", "cfLexeme2" } }, CitationForm = { { "en", "cfHomograph" } } });
176+
entry2.HomographNumber.Should().Be(2, "second entry with same CitationForm should get HomographNumber 2");
177+
178+
entry1 = await Api.GetEntry(entry1.Id) ?? throw new NullReferenceException();
179+
entry1.HomographNumber.Should().Be(1, "first entry should be updated to HomographNumber 1");
180+
}
181+
182+
[Fact]
183+
public async Task CreateEntry_AutoAssignsHomographNumber_ThreeEntries()
184+
{
185+
var entry1 = await Api.CreateEntry(new() { LexemeForm = { { "en", "triple" } } });
186+
entry1.HomographNumber.Should().Be(0);
187+
188+
var entry2 = await Api.CreateEntry(new() { LexemeForm = { { "en", "triple" } } });
189+
entry2.HomographNumber.Should().Be(2);
190+
191+
var entry3 = await Api.CreateEntry(new() { LexemeForm = { { "en", "triple" } } });
192+
entry3.HomographNumber.Should().Be(3, "third entry should get HomographNumber 3");
193+
194+
entry1 = await Api.GetEntry(entry1.Id) ?? throw new NullReferenceException();
195+
entry1.HomographNumber.Should().Be(1, "first entry should remain HomographNumber 1");
196+
}
197+
134198
[Fact]
135199
public async Task CanCreate_WithRichSpanTag()
136200
{

0 commit comments

Comments
 (0)