support for negations complete
This commit is contained in:
parent
dc59dcc778
commit
c2aae9cfe6
491
__tests__/negation.ts
Normal file
491
__tests__/negation.ts
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
import { Delta } from '../src/delta';
|
||||||
|
import { NegationHelper } from '../src/negation';
|
||||||
|
import { RhizomeNode } from '../src/node';
|
||||||
|
import { Lossless } from '../src/lossless';
|
||||||
|
|
||||||
|
describe('Negation System', () => {
|
||||||
|
let node: RhizomeNode;
|
||||||
|
let lossless: Lossless;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
node = new RhizomeNode();
|
||||||
|
lossless = new Lossless(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Negation Helper', () => {
|
||||||
|
it('should create negation deltas correctly', () => {
|
||||||
|
const originalDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'name', target: 'entity1', targetContext: 'name' },
|
||||||
|
{ localContext: 'value', target: 'Alice' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negationDelta = NegationHelper.createNegation(
|
||||||
|
originalDelta.id,
|
||||||
|
'moderator',
|
||||||
|
'host1'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(negationDelta.isNegation).toBe(true);
|
||||||
|
expect(negationDelta.negatedDeltaId).toBe(originalDelta.id);
|
||||||
|
expect(negationDelta.creator).toBe('moderator');
|
||||||
|
expect(negationDelta.pointers).toHaveLength(1);
|
||||||
|
expect(negationDelta.pointers[0]).toEqual({
|
||||||
|
localContext: 'negates',
|
||||||
|
target: originalDelta.id,
|
||||||
|
targetContext: 'negated_by'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify negation deltas', () => {
|
||||||
|
const regularDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'name', target: 'entity1', targetContext: 'name' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negationDelta = NegationHelper.createNegation(
|
||||||
|
'delta-to-negate',
|
||||||
|
'moderator',
|
||||||
|
'host1'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(NegationHelper.isNegationDelta(regularDelta)).toBe(false);
|
||||||
|
expect(NegationHelper.isNegationDelta(negationDelta)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract negated delta ID', () => {
|
||||||
|
const targetDeltaId = 'target-delta-123';
|
||||||
|
const negationDelta = NegationHelper.createNegation(
|
||||||
|
targetDeltaId,
|
||||||
|
'moderator',
|
||||||
|
'host1'
|
||||||
|
);
|
||||||
|
|
||||||
|
const extractedId = NegationHelper.getNegatedDeltaId(negationDelta);
|
||||||
|
expect(extractedId).toBe(targetDeltaId);
|
||||||
|
|
||||||
|
const regularDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'name', target: 'entity1', targetContext: 'name' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(NegationHelper.getNegatedDeltaId(regularDelta)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find negations for specific deltas', () => {
|
||||||
|
const delta1 = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'name', target: 'entity1', targetContext: 'name' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta2 = new Delta({
|
||||||
|
creator: 'user2',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'age', target: 'entity1', targetContext: 'age' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(delta1.id, 'mod1', 'host1');
|
||||||
|
const negation2 = NegationHelper.createNegation(delta1.id, 'mod2', 'host1');
|
||||||
|
const negation3 = NegationHelper.createNegation(delta2.id, 'mod1', 'host1');
|
||||||
|
|
||||||
|
const allDeltas = [delta1, delta2, negation1, negation2, negation3];
|
||||||
|
|
||||||
|
const negationsForDelta1 = NegationHelper.findNegationsFor(delta1.id, allDeltas);
|
||||||
|
expect(negationsForDelta1).toHaveLength(2);
|
||||||
|
expect(negationsForDelta1.map(d => d.id)).toContain(negation1.id);
|
||||||
|
expect(negationsForDelta1.map(d => d.id)).toContain(negation2.id);
|
||||||
|
|
||||||
|
const negationsForDelta2 = NegationHelper.findNegationsFor(delta2.id, allDeltas);
|
||||||
|
expect(negationsForDelta2).toHaveLength(1);
|
||||||
|
expect(negationsForDelta2[0].id).toBe(negation3.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check if deltas are negated', () => {
|
||||||
|
const delta1 = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'name', target: 'entity1', targetContext: 'name' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta2 = new Delta({
|
||||||
|
creator: 'user2',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'age', target: 'entity1', targetContext: 'age' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(delta1.id, 'mod1', 'host1');
|
||||||
|
const allDeltas = [delta1, delta2, negation1];
|
||||||
|
|
||||||
|
expect(NegationHelper.isDeltaNegated(delta1.id, allDeltas)).toBe(true);
|
||||||
|
expect(NegationHelper.isDeltaNegated(delta2.id, allDeltas)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out negated deltas', () => {
|
||||||
|
const delta1 = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'name', target: 'entity1', targetContext: 'name' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta2 = new Delta({
|
||||||
|
creator: 'user2',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'age', target: 'entity1', targetContext: 'age' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta3 = new Delta({
|
||||||
|
creator: 'user3',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'email', target: 'entity1', targetContext: 'email' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(delta1.id, 'mod1', 'host1');
|
||||||
|
const negation2 = NegationHelper.createNegation(delta2.id, 'mod2', 'host1');
|
||||||
|
|
||||||
|
const allDeltas = [delta1, delta2, delta3, negation1, negation2];
|
||||||
|
const filtered = NegationHelper.filterNegatedDeltas(allDeltas);
|
||||||
|
|
||||||
|
// Should only include delta3 (delta1 and delta2 are negated, negations themselves are filtered)
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe(delta3.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide negation statistics', () => {
|
||||||
|
const delta1 = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'name', target: 'entity1', targetContext: 'name' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta2 = new Delta({
|
||||||
|
creator: 'user2',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [{ localContext: 'age', target: 'entity1', targetContext: 'age' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(delta1.id, 'mod1', 'host1');
|
||||||
|
const allDeltas = [delta1, delta2, negation1];
|
||||||
|
|
||||||
|
const stats = NegationHelper.getNegationStats(allDeltas);
|
||||||
|
|
||||||
|
expect(stats.totalDeltas).toBe(3);
|
||||||
|
expect(stats.negationDeltas).toBe(1);
|
||||||
|
expect(stats.negatedDeltas).toBe(1);
|
||||||
|
expect(stats.effectiveDeltas).toBe(1); // only delta2 is effective
|
||||||
|
expect(stats.negatedDeltaIds).toContain(delta1.id);
|
||||||
|
expect(stats.negationMap.get(delta1.id)).toContain(negation1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply negations chronologically', () => {
|
||||||
|
const baseTime = Date.now();
|
||||||
|
|
||||||
|
// Create deltas with specific timestamps
|
||||||
|
const delta1 = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
timeCreated: baseTime,
|
||||||
|
pointers: [{ localContext: 'status', target: 'doc1', targetContext: 'status' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(delta1.id, 'mod1', 'host1');
|
||||||
|
negation1.timeCreated = baseTime + 1000; // 1 second later
|
||||||
|
|
||||||
|
const delta2 = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
timeCreated: baseTime + 2000, // 2 seconds later
|
||||||
|
pointers: [{ localContext: 'status', target: 'doc1', targetContext: 'status' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation2 = NegationHelper.createNegation(delta2.id, 'mod1', 'host1');
|
||||||
|
negation2.timeCreated = baseTime + 3000; // 3 seconds later
|
||||||
|
|
||||||
|
const allDeltas = [delta1, negation1, delta2, negation2];
|
||||||
|
const filtered = NegationHelper.applyNegationsChronologically(allDeltas);
|
||||||
|
|
||||||
|
// Both deltas should be negated
|
||||||
|
expect(filtered).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Lossless View Integration', () => {
|
||||||
|
it('should filter negated deltas in lossless views', () => {
|
||||||
|
// Create original delta
|
||||||
|
const originalDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'name', target: 'user123', targetContext: 'name' },
|
||||||
|
{ localContext: 'value', target: 'Alice' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create negation delta
|
||||||
|
const negationDelta = NegationHelper.createNegation(
|
||||||
|
originalDelta.id,
|
||||||
|
'moderator',
|
||||||
|
'host1'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Create another non-negated delta
|
||||||
|
const nonNegatedDelta = new Delta({
|
||||||
|
creator: 'user2',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'age', target: 'user123', targetContext: 'age' },
|
||||||
|
{ localContext: 'value', target: 25 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ingest all deltas
|
||||||
|
lossless.ingestDelta(originalDelta);
|
||||||
|
lossless.ingestDelta(negationDelta);
|
||||||
|
lossless.ingestDelta(nonNegatedDelta);
|
||||||
|
|
||||||
|
// Get view - should only show non-negated delta
|
||||||
|
const view = lossless.view(['user123']);
|
||||||
|
|
||||||
|
expect(view.user123).toBeDefined();
|
||||||
|
|
||||||
|
// Should only have age property (name was negated)
|
||||||
|
expect(view.user123.propertyDeltas.age).toHaveLength(1);
|
||||||
|
expect(view.user123.propertyDeltas.name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple negations of the same delta', () => {
|
||||||
|
const originalDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'content', target: 'post1', targetContext: 'content' },
|
||||||
|
{ localContext: 'value', target: 'Original content' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(originalDelta.id, 'mod1', 'host1');
|
||||||
|
const negation2 = NegationHelper.createNegation(originalDelta.id, 'mod2', 'host1');
|
||||||
|
|
||||||
|
lossless.ingestDelta(originalDelta);
|
||||||
|
lossless.ingestDelta(negation1);
|
||||||
|
lossless.ingestDelta(negation2);
|
||||||
|
|
||||||
|
const view = lossless.view(['post1']);
|
||||||
|
|
||||||
|
// Original delta should be negated (not visible)
|
||||||
|
expect(view.post1).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide negation statistics for entities', () => {
|
||||||
|
const delta1 = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'title', target: 'article1', targetContext: 'title' },
|
||||||
|
{ localContext: 'value', target: 'Original Title' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const delta2 = new Delta({
|
||||||
|
creator: 'user2',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'content', target: 'article1', targetContext: 'content' },
|
||||||
|
{ localContext: 'value', target: 'Article content' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(delta1.id, 'mod1', 'host1');
|
||||||
|
|
||||||
|
lossless.ingestDelta(delta1);
|
||||||
|
lossless.ingestDelta(delta2);
|
||||||
|
lossless.ingestDelta(negation1);
|
||||||
|
|
||||||
|
const stats = lossless.getNegationStats('article1');
|
||||||
|
|
||||||
|
expect(stats.totalDeltas).toBe(3);
|
||||||
|
expect(stats.negationDeltas).toBe(1);
|
||||||
|
expect(stats.negatedDeltas).toBe(1);
|
||||||
|
expect(stats.effectiveDeltas).toBe(1);
|
||||||
|
expect(stats.negationsByProperty.title.negated).toBe(1);
|
||||||
|
expect(stats.negationsByProperty.content.negated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve negation deltas for entities', () => {
|
||||||
|
const originalDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'status', target: 'task1', targetContext: 'status' },
|
||||||
|
{ localContext: 'value', target: 'pending' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negationDelta = NegationHelper.createNegation(
|
||||||
|
originalDelta.id,
|
||||||
|
'admin',
|
||||||
|
'host1'
|
||||||
|
);
|
||||||
|
|
||||||
|
lossless.ingestDelta(originalDelta);
|
||||||
|
lossless.ingestDelta(negationDelta);
|
||||||
|
|
||||||
|
const negations = lossless.getNegationDeltas('task1');
|
||||||
|
expect(negations).toHaveLength(1);
|
||||||
|
expect(negations[0].id).toBe(negationDelta.id);
|
||||||
|
expect(negations[0].creator).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negation in transactions', () => {
|
||||||
|
const transactionId = 'tx-negation';
|
||||||
|
|
||||||
|
// Create transaction declaration
|
||||||
|
lossless.ingestDelta(new Delta({
|
||||||
|
creator: 'system',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: '_transaction', target: transactionId, targetContext: 'size' },
|
||||||
|
{ localContext: 'size', target: 2 }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create original delta in transaction
|
||||||
|
const originalDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: '_transaction', target: transactionId, targetContext: 'deltas' },
|
||||||
|
{ localContext: 'comment', target: 'post1', targetContext: 'comments' },
|
||||||
|
{ localContext: 'text', target: 'Inappropriate comment' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create negation delta in same transaction
|
||||||
|
const negationDelta = NegationHelper.createNegation(originalDelta.id, 'moderator', 'host1');
|
||||||
|
negationDelta.pointers.unshift({
|
||||||
|
localContext: '_transaction',
|
||||||
|
target: transactionId,
|
||||||
|
targetContext: 'deltas'
|
||||||
|
});
|
||||||
|
|
||||||
|
lossless.ingestDelta(originalDelta);
|
||||||
|
lossless.ingestDelta(negationDelta);
|
||||||
|
|
||||||
|
// Transaction should complete, but original delta should be negated
|
||||||
|
const view = lossless.view(['post1']);
|
||||||
|
expect(view.post1).toBeUndefined(); // No visible deltas
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle chronological negation scenarios', () => {
|
||||||
|
const baseTime = Date.now();
|
||||||
|
|
||||||
|
// User posts content
|
||||||
|
const postDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
timeCreated: baseTime,
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'content', target: 'post1', targetContext: 'content' },
|
||||||
|
{ localContext: 'value', target: 'Original post' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Moderator negates it
|
||||||
|
const negationDelta = NegationHelper.createNegation(postDelta.id, 'moderator', 'host1');
|
||||||
|
negationDelta.timeCreated = baseTime + 1000;
|
||||||
|
|
||||||
|
// User edits content (after negation)
|
||||||
|
const editDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
timeCreated: baseTime + 2000,
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'content', target: 'post1', targetContext: 'content' },
|
||||||
|
{ localContext: 'value', target: 'Edited post' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
lossless.ingestDelta(postDelta);
|
||||||
|
lossless.ingestDelta(negationDelta);
|
||||||
|
lossless.ingestDelta(editDelta);
|
||||||
|
|
||||||
|
const view = lossless.view(['post1']);
|
||||||
|
|
||||||
|
// Should show edited content (edit happened after negation)
|
||||||
|
expect(view.post1).toBeDefined();
|
||||||
|
expect(view.post1.propertyDeltas.content).toHaveLength(1);
|
||||||
|
|
||||||
|
// The visible delta should be the edit delta
|
||||||
|
const visibleDelta = view.post1.propertyDeltas.content[0];
|
||||||
|
expect(visibleDelta.id).toBe(editDelta.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle negation of non-existent deltas', () => {
|
||||||
|
const negationDelta = NegationHelper.createNegation(
|
||||||
|
'non-existent-delta-id',
|
||||||
|
'moderator',
|
||||||
|
'host1'
|
||||||
|
);
|
||||||
|
|
||||||
|
lossless.ingestDelta(negationDelta);
|
||||||
|
|
||||||
|
// Should not crash and stats should reflect the orphaned negation
|
||||||
|
const stats = lossless.getNegationStats('entity1');
|
||||||
|
expect(stats.negationDeltas).toBe(0); // No negations for this entity
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple negations and un-negations', () => {
|
||||||
|
const originalDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'visible', target: 'item1', targetContext: 'visible' },
|
||||||
|
{ localContext: 'value', target: true }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negation1 = NegationHelper.createNegation(originalDelta.id, 'mod1', 'host1');
|
||||||
|
const negation2 = NegationHelper.createNegation(originalDelta.id, 'mod2', 'host1');
|
||||||
|
|
||||||
|
lossless.ingestDelta(originalDelta);
|
||||||
|
lossless.ingestDelta(negation1);
|
||||||
|
lossless.ingestDelta(negation2);
|
||||||
|
|
||||||
|
// Delta should be thoroughly negated
|
||||||
|
const view = lossless.view(['item1']);
|
||||||
|
expect(view.item1).toBeUndefined();
|
||||||
|
|
||||||
|
const stats = lossless.getNegationStats('item1');
|
||||||
|
expect(stats.negatedDeltas).toBe(1);
|
||||||
|
expect(stats.negationDeltas).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle self-referential entities in negations', () => {
|
||||||
|
// Create a delta that references itself
|
||||||
|
const selfRefDelta = new Delta({
|
||||||
|
creator: 'user1',
|
||||||
|
host: 'host1',
|
||||||
|
pointers: [
|
||||||
|
{ localContext: 'parent', target: 'node1', targetContext: 'parent' },
|
||||||
|
{ localContext: 'child', target: 'node1' } // Self-reference
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const negationDelta = NegationHelper.createNegation(selfRefDelta.id, 'admin', 'host1');
|
||||||
|
|
||||||
|
lossless.ingestDelta(selfRefDelta);
|
||||||
|
lossless.ingestDelta(negationDelta);
|
||||||
|
|
||||||
|
const view = lossless.view(['node1']);
|
||||||
|
expect(view.node1).toBeUndefined(); // Should be negated
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
166
src/lossless.ts
166
src/lossless.ts
@ -7,6 +7,8 @@ import {Delta, DeltaFilter, DeltaID, DeltaNetworkImageV1} from './delta';
|
|||||||
import {RhizomeNode} from './node';
|
import {RhizomeNode} from './node';
|
||||||
import {Transactions} from './transactions';
|
import {Transactions} from './transactions';
|
||||||
import {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "./types";
|
import {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "./types";
|
||||||
|
import {Negation} from './negation';
|
||||||
|
import {NegationHelper} from './negation';
|
||||||
const debug = Debug('rz:lossless');
|
const debug = Debug('rz:lossless');
|
||||||
|
|
||||||
export type CollapsedPointer = {[key: PropertyID]: PropertyTypes};
|
export type CollapsedPointer = {[key: PropertyID]: PropertyTypes};
|
||||||
@ -67,6 +69,11 @@ export class Lossless {
|
|||||||
transactions: Transactions;
|
transactions: Transactions;
|
||||||
referencedAs = new Map<string, Set<DomainEntityID>>();
|
referencedAs = new Map<string, Set<DomainEntityID>>();
|
||||||
eventStream = new EventEmitter();
|
eventStream = new EventEmitter();
|
||||||
|
|
||||||
|
// Track all deltas by ID for negation processing
|
||||||
|
private allDeltas = new Map<DeltaID, Delta>();
|
||||||
|
// Track which entities are affected by each delta
|
||||||
|
private deltaToEntities = new Map<DeltaID, Set<DomainEntityID>>();
|
||||||
|
|
||||||
constructor(readonly rhizomeNode: RhizomeNode) {
|
constructor(readonly rhizomeNode: RhizomeNode) {
|
||||||
this.transactions = new Transactions(this);
|
this.transactions = new Transactions(this);
|
||||||
@ -81,20 +88,61 @@ export class Lossless {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ingestDelta(delta: Delta): TransactionID | undefined {
|
ingestDelta(delta: Delta): TransactionID | undefined {
|
||||||
const targets = delta.pointers
|
// Store delta for negation processing
|
||||||
.filter(({targetContext}) => !!targetContext)
|
this.allDeltas.set(delta.id, delta);
|
||||||
.map(({target}) => target)
|
|
||||||
.filter((target) => typeof target === 'string')
|
|
||||||
|
|
||||||
for (const target of targets) {
|
let targets: string[] = [];
|
||||||
let ent = this.domainEntities.get(target);
|
|
||||||
|
|
||||||
if (!ent) {
|
// Handle negation deltas specially
|
||||||
ent = new LosslessEntity(this, target);
|
if (NegationHelper.isNegationDelta(delta)) {
|
||||||
this.domainEntities.set(target, ent);
|
const negatedDeltaId = NegationHelper.getNegatedDeltaId(delta);
|
||||||
|
if (negatedDeltaId) {
|
||||||
|
// Find which entities were affected by the negated delta
|
||||||
|
const affectedEntities = this.deltaToEntities.get(negatedDeltaId);
|
||||||
|
if (affectedEntities) {
|
||||||
|
targets = Array.from(affectedEntities);
|
||||||
|
// Track which entities this negation delta affects
|
||||||
|
this.deltaToEntities.set(delta.id, affectedEntities);
|
||||||
|
|
||||||
|
// Add the negation delta to all affected entities
|
||||||
|
for (const entityId of affectedEntities) {
|
||||||
|
let ent = this.domainEntities.get(entityId);
|
||||||
|
if (!ent) {
|
||||||
|
ent = new LosslessEntity(this, entityId);
|
||||||
|
this.domainEntities.set(entityId, ent);
|
||||||
|
}
|
||||||
|
// Add negation delta to the entity
|
||||||
|
// For negation deltas, we need to add them to a special property
|
||||||
|
// since they don't directly target the entity
|
||||||
|
let negationDeltas = ent.properties.get('_negations');
|
||||||
|
if (!negationDeltas) {
|
||||||
|
negationDeltas = new Set<Delta>();
|
||||||
|
ent.properties.set('_negations', negationDeltas);
|
||||||
|
}
|
||||||
|
negationDeltas.add(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Regular delta processing
|
||||||
|
targets = delta.pointers
|
||||||
|
.filter(({targetContext}) => !!targetContext)
|
||||||
|
.map(({target}) => target)
|
||||||
|
.filter((target) => typeof target === 'string');
|
||||||
|
|
||||||
ent.addDelta(delta);
|
// Track which entities this delta affects
|
||||||
|
this.deltaToEntities.set(delta.id, new Set(targets));
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
let ent = this.domainEntities.get(target);
|
||||||
|
|
||||||
|
if (!ent) {
|
||||||
|
ent = new LosslessEntity(this, target);
|
||||||
|
this.domainEntities.set(target, ent);
|
||||||
|
}
|
||||||
|
|
||||||
|
ent.addDelta(delta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const {target, localContext} of delta.pointers) {
|
for (const {target, localContext} of delta.pointers) {
|
||||||
@ -150,10 +198,22 @@ export class Lossless {
|
|||||||
|
|
||||||
let hasVisibleDeltas = false;
|
let hasVisibleDeltas = false;
|
||||||
|
|
||||||
|
// First, collect all deltas for this entity to properly apply negations
|
||||||
|
const allEntityDeltas: Delta[] = [];
|
||||||
|
for (const deltas of ent.properties.values()) {
|
||||||
|
allEntityDeltas.push(...Array.from(deltas));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply negation filtering to all deltas for this entity
|
||||||
|
const nonNegatedDeltas = Negation.filterNegatedDeltas(allEntityDeltas);
|
||||||
|
const nonNegatedDeltaIds = new Set(nonNegatedDeltas.map(d => d.id));
|
||||||
|
|
||||||
for (const [key, deltas] of ent.properties.entries()) {
|
for (const [key, deltas] of ent.properties.entries()) {
|
||||||
|
// Filter deltas for this property based on negation status
|
||||||
|
const filteredDeltas = Array.from(deltas).filter(delta => nonNegatedDeltaIds.has(delta.id));
|
||||||
const visibleDeltas: CollapsedDelta[] = [];
|
const visibleDeltas: CollapsedDelta[] = [];
|
||||||
|
|
||||||
for (const delta of deltas) {
|
for (const delta of filteredDeltas) {
|
||||||
if (deltaFilter && !deltaFilter(delta)) {
|
if (deltaFilter && !deltaFilter(delta)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -201,5 +261,89 @@ export class Lossless {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get negation statistics for an entity
|
||||||
|
getNegationStats(entityId: DomainEntityID): {
|
||||||
|
totalDeltas: number;
|
||||||
|
negationDeltas: number;
|
||||||
|
negatedDeltas: number;
|
||||||
|
effectiveDeltas: number;
|
||||||
|
negationsByProperty: { [key: PropertyID]: { negated: number; total: number } };
|
||||||
|
} {
|
||||||
|
const ent = this.domainEntities.get(entityId);
|
||||||
|
if (!ent) {
|
||||||
|
return {
|
||||||
|
totalDeltas: 0,
|
||||||
|
negationDeltas: 0,
|
||||||
|
negatedDeltas: 0,
|
||||||
|
effectiveDeltas: 0,
|
||||||
|
negationsByProperty: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all deltas for this entity, including negation deltas
|
||||||
|
const allEntityDeltas: Delta[] = [];
|
||||||
|
for (const deltas of ent.properties.values()) {
|
||||||
|
allEntityDeltas.push(...Array.from(deltas));
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDeltas = 0;
|
||||||
|
let totalNegationDeltas = 0;
|
||||||
|
let totalNegatedDeltas = 0;
|
||||||
|
let totalEffectiveDeltas = 0;
|
||||||
|
const negationsByProperty: { [key: PropertyID]: { negated: number; total: number } } = {};
|
||||||
|
|
||||||
|
// Get all negation deltas for this entity
|
||||||
|
const negationDeltas = this.getNegationDeltas(entityId);
|
||||||
|
const negatedDeltaIds = new Set<DeltaID>();
|
||||||
|
|
||||||
|
for (const negDelta of negationDeltas) {
|
||||||
|
const negatedId = NegationHelper.getNegatedDeltaId(negDelta);
|
||||||
|
if (negatedId) {
|
||||||
|
negatedDeltaIds.add(negatedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [property, deltas] of ent.properties.entries()) {
|
||||||
|
// Skip the special _negations property in the per-property stats
|
||||||
|
if (property === '_negations') {
|
||||||
|
totalDeltas += deltas.size;
|
||||||
|
totalNegationDeltas += deltas.size;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaArray = Array.from(deltas);
|
||||||
|
const propertyNegatedCount = deltaArray.filter(d => negatedDeltaIds.has(d.id)).length;
|
||||||
|
const propertyTotal = deltaArray.length;
|
||||||
|
|
||||||
|
totalDeltas += propertyTotal;
|
||||||
|
totalNegatedDeltas += propertyNegatedCount;
|
||||||
|
totalEffectiveDeltas += (propertyTotal - propertyNegatedCount);
|
||||||
|
|
||||||
|
negationsByProperty[property] = {
|
||||||
|
negated: propertyNegatedCount,
|
||||||
|
total: propertyTotal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDeltas,
|
||||||
|
negationDeltas: totalNegationDeltas,
|
||||||
|
negatedDeltas: totalNegatedDeltas,
|
||||||
|
effectiveDeltas: totalEffectiveDeltas,
|
||||||
|
negationsByProperty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all negation deltas for an entity
|
||||||
|
getNegationDeltas(entityId: DomainEntityID): Delta[] {
|
||||||
|
const ent = this.domainEntities.get(entityId);
|
||||||
|
if (!ent) return [];
|
||||||
|
|
||||||
|
const negationProperty = ent.properties.get('_negations');
|
||||||
|
if (!negationProperty) return [];
|
||||||
|
|
||||||
|
return Array.from(negationProperty);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: point-in-time queries
|
// TODO: point-in-time queries
|
||||||
}
|
}
|
||||||
|
209
src/negation.ts
Normal file
209
src/negation.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import Debug from 'debug';
|
||||||
|
import { Delta, DeltaID } from './delta';
|
||||||
|
import { CreatorID, HostID } from './types';
|
||||||
|
|
||||||
|
const debug = Debug('rz:negation');
|
||||||
|
|
||||||
|
// Negation-specific types
|
||||||
|
export interface NegationPointer {
|
||||||
|
localContext: 'negates';
|
||||||
|
target: DeltaID;
|
||||||
|
targetContext: 'negated_by';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NegationDelta extends Delta {
|
||||||
|
isNegation: true;
|
||||||
|
negatedDeltaId: DeltaID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for creating and identifying negation deltas
|
||||||
|
export class NegationHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a negation delta that negates another delta
|
||||||
|
*/
|
||||||
|
static createNegation(
|
||||||
|
deltaToNegate: DeltaID,
|
||||||
|
creator: CreatorID,
|
||||||
|
host: HostID
|
||||||
|
): NegationDelta {
|
||||||
|
const negationDelta = new Delta({
|
||||||
|
creator,
|
||||||
|
host,
|
||||||
|
pointers: [{
|
||||||
|
localContext: 'negates',
|
||||||
|
target: deltaToNegate,
|
||||||
|
targetContext: 'negated_by'
|
||||||
|
}]
|
||||||
|
}) as NegationDelta;
|
||||||
|
|
||||||
|
negationDelta.isNegation = true;
|
||||||
|
negationDelta.negatedDeltaId = deltaToNegate;
|
||||||
|
|
||||||
|
debug(`Created negation delta ${negationDelta.id} negating ${deltaToNegate}`);
|
||||||
|
return negationDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a delta is a negation delta
|
||||||
|
*/
|
||||||
|
static isNegationDelta(delta: Delta): delta is NegationDelta {
|
||||||
|
return delta.pointers.some(pointer =>
|
||||||
|
pointer.localContext === 'negates' &&
|
||||||
|
pointer.targetContext === 'negated_by'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the negated delta ID from a negation delta
|
||||||
|
*/
|
||||||
|
static getNegatedDeltaId(negationDelta: Delta): DeltaID | null {
|
||||||
|
const negationPointer = negationDelta.pointers.find(pointer =>
|
||||||
|
pointer.localContext === 'negates' &&
|
||||||
|
pointer.targetContext === 'negated_by'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (negationPointer && typeof negationPointer.target === 'string') {
|
||||||
|
return negationPointer.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all negation deltas that negate a specific delta
|
||||||
|
*/
|
||||||
|
static findNegationsFor(targetDeltaId: DeltaID, deltas: Delta[]): NegationDelta[] {
|
||||||
|
return deltas
|
||||||
|
.filter(delta => this.isNegationDelta(delta))
|
||||||
|
.filter(delta => this.getNegatedDeltaId(delta) === targetDeltaId) as NegationDelta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a delta is negated by any negation deltas
|
||||||
|
*/
|
||||||
|
static isDeltaNegated(deltaId: DeltaID, deltas: Delta[]): boolean {
|
||||||
|
return this.findNegationsFor(deltaId, deltas).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out negated deltas from a list
|
||||||
|
* Returns deltas that are not negated by any negation deltas in the list
|
||||||
|
*/
|
||||||
|
static filterNegatedDeltas(deltas: Delta[]): Delta[] {
|
||||||
|
const negatedDeltaIds = new Set<DeltaID>();
|
||||||
|
|
||||||
|
// First pass: collect all negated delta IDs
|
||||||
|
for (const delta of deltas) {
|
||||||
|
if (this.isNegationDelta(delta)) {
|
||||||
|
const negatedId = this.getNegatedDeltaId(delta);
|
||||||
|
if (negatedId) {
|
||||||
|
negatedDeltaIds.add(negatedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: filter out negated deltas and negation deltas themselves
|
||||||
|
return deltas.filter(delta => {
|
||||||
|
// Exclude negation deltas themselves (they're metadata)
|
||||||
|
if (this.isNegationDelta(delta)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude deltas that have been negated
|
||||||
|
if (negatedDeltaIds.has(delta.id)) {
|
||||||
|
debug(`Filtering out negated delta ${delta.id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get negation statistics for a list of deltas
|
||||||
|
*/
|
||||||
|
static getNegationStats(deltas: Delta[]): {
|
||||||
|
totalDeltas: number;
|
||||||
|
negationDeltas: number;
|
||||||
|
negatedDeltas: number;
|
||||||
|
effectiveDeltas: number;
|
||||||
|
negatedDeltaIds: DeltaID[];
|
||||||
|
negationMap: Map<DeltaID, DeltaID[]>; // negated -> [negating deltas]
|
||||||
|
} {
|
||||||
|
const negationDeltas = deltas.filter(d => this.isNegationDelta(d));
|
||||||
|
const negatedDeltaIds = new Set<DeltaID>();
|
||||||
|
const negationMap = new Map<DeltaID, DeltaID[]>();
|
||||||
|
|
||||||
|
for (const negDelta of negationDeltas) {
|
||||||
|
const negatedId = this.getNegatedDeltaId(negDelta);
|
||||||
|
if (negatedId) {
|
||||||
|
negatedDeltaIds.add(negatedId);
|
||||||
|
|
||||||
|
if (!negationMap.has(negatedId)) {
|
||||||
|
negationMap.set(negatedId, []);
|
||||||
|
}
|
||||||
|
negationMap.get(negatedId)!.push(negDelta.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveDeltas = deltas.length - negationDeltas.length - negatedDeltaIds.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDeltas: deltas.length,
|
||||||
|
negationDeltas: negationDeltas.length,
|
||||||
|
negatedDeltas: negatedDeltaIds.size,
|
||||||
|
effectiveDeltas,
|
||||||
|
negatedDeltaIds: Array.from(negatedDeltaIds),
|
||||||
|
negationMap
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply negations to a delta stream in chronological order
|
||||||
|
* Later negations can override earlier ones
|
||||||
|
*/
|
||||||
|
static applyNegationsChronologically(deltas: Delta[]): Delta[] {
|
||||||
|
// Sort by timestamp to apply negations in order
|
||||||
|
const sortedDeltas = [...deltas].sort((a, b) => a.timeCreated - b.timeCreated);
|
||||||
|
const negatedIds = new Set<DeltaID>();
|
||||||
|
const unnegatedIds = new Set<DeltaID>();
|
||||||
|
|
||||||
|
// Process deltas in chronological order
|
||||||
|
for (const delta of sortedDeltas) {
|
||||||
|
if (this.isNegationDelta(delta)) {
|
||||||
|
const negatedId = this.getNegatedDeltaId(delta);
|
||||||
|
if (negatedId) {
|
||||||
|
negatedIds.add(negatedId);
|
||||||
|
unnegatedIds.delete(negatedId); // Remove from unnegated if it was there
|
||||||
|
debug(`Chronologically negated delta ${negatedId} at time ${delta.timeCreated}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If this delta was previously negated, it might be reinstated by this newer delta
|
||||||
|
if (negatedIds.has(delta.id)) {
|
||||||
|
// Check if there are any negations after this delta's timestamp
|
||||||
|
const laterNegations = sortedDeltas
|
||||||
|
.filter(d => d.timeCreated > delta.timeCreated)
|
||||||
|
.filter(d => this.isNegationDelta(d))
|
||||||
|
.filter(d => this.getNegatedDeltaId(d) === delta.id);
|
||||||
|
|
||||||
|
if (laterNegations.length === 0) {
|
||||||
|
unnegatedIds.add(delta.id);
|
||||||
|
negatedIds.delete(delta.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter based on final negation state
|
||||||
|
return deltas.filter(delta => {
|
||||||
|
if (this.isNegationDelta(delta)) {
|
||||||
|
return false; // Remove negation deltas from final result
|
||||||
|
}
|
||||||
|
return !negatedIds.has(delta.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance for convenience
|
||||||
|
export const Negation = NegationHelper;
|
12
todo.md
12
todo.md
@ -26,12 +26,12 @@ This document tracks work needed to achieve full specification compliance, organ
|
|||||||
|
|
||||||
## Phase 2: Core Features (Spec Compliance)
|
## Phase 2: Core Features (Spec Compliance)
|
||||||
|
|
||||||
### 2.1 Negation Deltas
|
### 2.1 Negation Deltas ✅
|
||||||
- [ ] Implement negation delta type with "negates" pointer
|
- [x] Implement negation delta type with "negates" pointer
|
||||||
- [ ] Add "negated_by" context handling
|
- [x] Add "negated_by" context handling
|
||||||
- [ ] Update lossless view to handle negations
|
- [x] Update lossless view to handle negations
|
||||||
- [ ] Update lossy resolvers to respect negations
|
- [x] Update lossy resolvers to respect negations
|
||||||
- [ ] Add comprehensive negation tests
|
- [x] Add comprehensive negation tests
|
||||||
|
|
||||||
### 2.2 Advanced Conflict Resolution
|
### 2.2 Advanced Conflict Resolution
|
||||||
- [ ] Implement numeric aggregation resolvers (min/max/sum/average)
|
- [ ] Implement numeric aggregation resolvers (min/max/sum/average)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user