From c2aae9cfe67b8cae30d6ef168a65863692a5911a Mon Sep 17 00:00:00 2001 From: Mykola Bilokonsky Date: Mon, 9 Jun 2025 13:39:25 -0400 Subject: [PATCH] support for negations complete --- __tests__/negation.ts | 491 ++++++++++++++++++++++++++++++++++++++++++ src/lossless.ts | 166 +++++++++++++- src/negation.ts | 209 ++++++++++++++++++ todo.md | 12 +- 4 files changed, 861 insertions(+), 17 deletions(-) create mode 100644 __tests__/negation.ts create mode 100644 src/negation.ts diff --git a/__tests__/negation.ts b/__tests__/negation.ts new file mode 100644 index 0000000..c7b84ab --- /dev/null +++ b/__tests__/negation.ts @@ -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 + }); + }); +}); \ No newline at end of file diff --git a/src/lossless.ts b/src/lossless.ts index 0665c9e..5762cf3 100644 --- a/src/lossless.ts +++ b/src/lossless.ts @@ -7,6 +7,8 @@ import {Delta, DeltaFilter, DeltaID, DeltaNetworkImageV1} from './delta'; import {RhizomeNode} from './node'; import {Transactions} from './transactions'; import {DomainEntityID, PropertyID, PropertyTypes, TransactionID, ViewMany} from "./types"; +import {Negation} from './negation'; +import {NegationHelper} from './negation'; const debug = Debug('rz:lossless'); export type CollapsedPointer = {[key: PropertyID]: PropertyTypes}; @@ -67,6 +69,11 @@ export class Lossless { transactions: Transactions; referencedAs = new Map>(); eventStream = new EventEmitter(); + + // Track all deltas by ID for negation processing + private allDeltas = new Map(); + // Track which entities are affected by each delta + private deltaToEntities = new Map>(); constructor(readonly rhizomeNode: RhizomeNode) { this.transactions = new Transactions(this); @@ -81,20 +88,61 @@ export class Lossless { } ingestDelta(delta: Delta): TransactionID | undefined { - const targets = delta.pointers - .filter(({targetContext}) => !!targetContext) - .map(({target}) => target) - .filter((target) => typeof target === 'string') + // Store delta for negation processing + this.allDeltas.set(delta.id, delta); - for (const target of targets) { - let ent = this.domainEntities.get(target); + let targets: string[] = []; - if (!ent) { - ent = new LosslessEntity(this, target); - this.domainEntities.set(target, ent); + // Handle negation deltas specially + if (NegationHelper.isNegationDelta(delta)) { + 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(); + 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) { @@ -150,10 +198,22 @@ export class Lossless { 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()) { + // Filter deltas for this property based on negation status + const filteredDeltas = Array.from(deltas).filter(delta => nonNegatedDeltaIds.has(delta.id)); const visibleDeltas: CollapsedDelta[] = []; - for (const delta of deltas) { + for (const delta of filteredDeltas) { if (deltaFilter && !deltaFilter(delta)) { continue; } @@ -201,5 +261,89 @@ export class Lossless { 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(); + + 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 } diff --git a/src/negation.ts b/src/negation.ts new file mode 100644 index 0000000..b4f67f4 --- /dev/null +++ b/src/negation.ts @@ -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(); + + // 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; // negated -> [negating deltas] + } { + const negationDeltas = deltas.filter(d => this.isNegationDelta(d)); + const negatedDeltaIds = new Set(); + const negationMap = new Map(); + + 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(); + const unnegatedIds = new Set(); + + // 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; \ No newline at end of file diff --git a/todo.md b/todo.md index 59dc6fe..c3b023b 100644 --- a/todo.md +++ b/todo.md @@ -26,12 +26,12 @@ This document tracks work needed to achieve full specification compliance, organ ## Phase 2: Core Features (Spec Compliance) -### 2.1 Negation Deltas -- [ ] Implement negation delta type with "negates" pointer -- [ ] Add "negated_by" context handling -- [ ] Update lossless view to handle negations -- [ ] Update lossy resolvers to respect negations -- [ ] Add comprehensive negation tests +### 2.1 Negation Deltas ✅ +- [x] Implement negation delta type with "negates" pointer +- [x] Add "negated_by" context handling +- [x] Update lossless view to handle negations +- [x] Update lossy resolvers to respect negations +- [x] Add comprehensive negation tests ### 2.2 Advanced Conflict Resolution - [ ] Implement numeric aggregation resolvers (min/max/sum/average)