diff --git a/__tests__/negation.ts b/__tests__/negation.ts index 5bdee41..857f97a 100644 --- a/__tests__/negation.ts +++ b/__tests__/negation.ts @@ -1,8 +1,11 @@ +import Debug from 'debug'; import { Delta } from '../src/core'; import { NegationHelper } from '../src/features'; import { RhizomeNode } from '../src/node'; import { Lossless } from '../src/views'; +const debug = Debug('rz:negation:test'); + describe('Negation System', () => { let node: RhizomeNode; let lossless: Lossless; @@ -442,32 +445,6 @@ describe('Negation System', () => { 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({ @@ -487,5 +464,165 @@ describe('Negation System', () => { const view = lossless.view(['node1']); expect(view.node1).toBeUndefined(); // Should be negated }); + + it('should handle multiple direct negations of the same delta', () => { + const testNode = new RhizomeNode(); + const testLossless = new Lossless(testNode); + + // Create the original delta + const originalDelta = new Delta({ + creator: 'user1', + host: 'host1', + pointers: [ + { localContext: 'title', target: 'entity2', targetContext: 'title' }, + { localContext: 'status', target: 'Draft' } + ] + }); + + // Create two negations of the same delta + const negation1 = NegationHelper.createNegation(originalDelta.id, 'user2', 'host1'); + const negation2 = NegationHelper.createNegation(originalDelta.id, 'user3', 'host1'); + + // Process all deltas + testLossless.ingestDelta(originalDelta); + testLossless.ingestDelta(negation1); + testLossless.ingestDelta(negation2); + + // Get the view after processing all deltas + const view = testLossless.view(['entity2']); + + // The original delta should be negated (not in view) because it has two direct negations + expect(view.entity2).toBeUndefined(); + + // Verify the stats + const stats = testLossless.getNegationStats('entity2'); + expect(stats.negationDeltas).toBe(2); + expect(stats.negatedDeltas).toBe(1); + expect(stats.effectiveDeltas).toBe(0); + }); + + it('should handle complex negation chains', () => { + const testNode = new RhizomeNode(); + const testLossless = new Lossless(testNode); + + // Create the original delta + const deltaA = new Delta({ + creator: 'user1', + host: 'host1', + pointers: [ + { localContext: 'content', target: 'entity3', targetContext: 'content' }, + { localContext: 'text', target: 'Hello World' } + ] + }); + + // Create a chain of negations: B negates A, C negates B, D negates C + const deltaB = NegationHelper.createNegation(deltaA.id, 'user2', 'host1'); + const deltaC = NegationHelper.createNegation(deltaB.id, 'user3', 'host1'); + const deltaD = NegationHelper.createNegation(deltaC.id, 'user4', 'host1'); + + debug('Delta A (original): %s', deltaA.id); + debug('Delta B (negates A): %s', deltaB.id); + debug('Delta C (negates B): %s', deltaC.id); + debug('Delta D (negates C): %s', deltaD.id); + + // Process all deltas in order + testLossless.ingestDelta(deltaA); + testLossless.ingestDelta(deltaB); + testLossless.ingestDelta(deltaC); + testLossless.ingestDelta(deltaD); + + // Get the view after processing all deltas + const view = testLossless.view(['entity3']); + + // The original delta should be negated because: + // - B negates A + // - C negates B (so A is no longer negated) + // - D negates C (so B is no longer negated, and A is negated again by B) + expect(view.entity3).toBeUndefined(); + + // Get all deltas for the entity + const allDeltas = [deltaA, deltaB, deltaC, deltaD]; + + // Get the stats + const stats = testLossless.getNegationStats('entity3'); + const isANegated = NegationHelper.isDeltaNegated(deltaA.id, allDeltas); + const isBNegated = NegationHelper.isDeltaNegated(deltaB.id, allDeltas); + const isCNegated = NegationHelper.isDeltaNegated(deltaC.id, allDeltas); + const isDNegated = NegationHelper.isDeltaNegated(deltaD.id, allDeltas); + + debug('Delta statuses:'); + debug('- A (%s): %s', deltaA.id, isANegated ? 'NEGATED' : 'ACTIVE'); + debug('- B (%s): %s, negates: %s', deltaB.id, isBNegated ? 'NEGATED' : 'ACTIVE', NegationHelper.getNegatedDeltaId(deltaB)); + debug('- C (%s): %s, negates: %s', deltaC.id, isCNegated ? 'NEGATED' : 'ACTIVE', NegationHelper.getNegatedDeltaId(deltaC)); + debug('- D (%s): %s, negates: %s', deltaD.id, isDNegated ? 'NEGATED' : 'ACTIVE', NegationHelper.getNegatedDeltaId(deltaD)); + + debug('Negation stats: %O', { + totalDeltas: stats.totalDeltas, + negationDeltas: stats.negationDeltas, + negatedDeltas: stats.negatedDeltas, + effectiveDeltas: stats.effectiveDeltas, + negationsByProperty: stats.negationsByProperty + }); + + // B, C, D are negation deltas + expect(stats.negationDeltas).toBe(3); + + // A and C are effectively negated + expect(isANegated).toBe(true); + expect(isCNegated).toBe(true); + + // B and D are not negated (they are negation deltas that are not themselves negated) + expect(isBNegated).toBe(false); + expect(isDNegated).toBe(false); + + // No deltas remain unnegated + expect(stats.effectiveDeltas).toBe(0); + }); + + it('should handle multiple independent negations', () => { + const testNode = new RhizomeNode(); + const testLossless = new Lossless(testNode); + + // Create two independent deltas + const delta1 = new Delta({ + creator: 'user1', + host: 'host1', + pointers: [ + { localContext: 'item', target: 'entity4', targetContext: 'item' }, + { localContext: 'name', target: 'Item 1' } + ] + }); + + const delta2 = new Delta({ + creator: 'user2', + host: 'host1', + pointers: [ + { localContext: 'item', target: 'entity4', targetContext: 'item' }, + { localContext: 'name', target: 'Item 2' } + ] + }); + + // Create negations for both deltas + const negation1 = NegationHelper.createNegation(delta1.id, 'user3', 'host1'); + const negation2 = NegationHelper.createNegation(delta2.id, 'user4', 'host1'); + + // Process all deltas + testLossless.ingestDelta(delta1); + testLossless.ingestDelta(delta2); + testLossless.ingestDelta(negation1); + testLossless.ingestDelta(negation2); + + // Get the view after processing all deltas + const view = testLossless.view(['entity4']); + + // Both deltas should be negated + expect(view.entity4).toBeUndefined(); + + // Verify the stats + const stats = testLossless.getNegationStats('entity4'); + expect(stats.negationDeltas).toBe(2); + expect(stats.negatedDeltas).toBe(2); + expect(stats.effectiveDeltas).toBe(0); + }); }); }); \ No newline at end of file diff --git a/src/features/negation.ts b/src/features/negation.ts index a7b8580..232834b 100644 --- a/src/features/negation.ts +++ b/src/features/negation.ts @@ -81,38 +81,140 @@ export class NegationHelper { /** * Check if a delta is negated by any negation deltas + * @param deltaId The ID of the delta to check + * @param deltas The list of all deltas to consider + * @returns True if the delta is effectively negated, false otherwise */ 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(); + // Create a map of delta ID to its negation status + const deltaStatus = new Map(); + // Create a map of delta ID to its negation deltas + const deltaToNegations = new Map(); - // First pass: collect all negated delta IDs + // First pass: collect all deltas and their negations for (const delta of deltas) { if (this.isNegationDelta(delta)) { const negatedId = this.getNegatedDeltaId(delta); if (negatedId) { - negatedDeltaIds.add(negatedId); + if (!deltaToNegations.has(negatedId)) { + deltaToNegations.set(negatedId, []); + } + deltaToNegations.get(negatedId)!.push(delta); } } } - // Second pass: filter out negated deltas and negation deltas themselves + // Function to determine if a delta is effectively negated + const isEffectivelyNegated = (currentDeltaId: DeltaID, visited: Set = new Set()): boolean => { + // Avoid infinite recursion in case of cycles + if (visited.has(currentDeltaId)) { + return false; // If we've seen this delta before, assume it's not negated to break the cycle + } + + // Check if we've already determined this delta's status + if (deltaStatus.has(currentDeltaId)) { + return deltaStatus.get(currentDeltaId)!; + } + + // Get all negations targeting this delta + const negations = deltaToNegations.get(currentDeltaId) || []; + + // If there are no negations, the delta is not negated + if (negations.length === 0) { + deltaStatus.set(currentDeltaId, false); + return false; + } + + // Check each negation to see if it's effectively applied + // A negation is effective if it's not itself negated + for (const negation of negations) { + // If the negation delta is not itself negated, then the target is negated + if (!isEffectivelyNegated(negation.id, new Set([...visited, currentDeltaId]))) { + deltaStatus.set(currentDeltaId, true); + return true; + } + } + + // If all negations are themselves negated, the delta is not negated + deltaStatus.set(currentDeltaId, false); + return false; + }; + + // Check if the target delta is negated + return isEffectivelyNegated(deltaId); + } + + /** + * Filter out negated deltas from a list, handling both direct and indirect negations + * Returns deltas that are not effectively negated by any chain of negations + */ + static filterNegatedDeltas(deltas: Delta[]): Delta[] { + // Create a map of delta ID to its negation status + const deltaStatus = new Map(); + // Create a map of delta ID to its negation deltas + const deltaToNegations = new Map(); + + // First pass: collect all deltas and their negations + for (const delta of deltas) { + if (this.isNegationDelta(delta)) { + const negatedId = this.getNegatedDeltaId(delta); + if (negatedId) { + if (!deltaToNegations.has(negatedId)) { + deltaToNegations.set(negatedId, []); + } + deltaToNegations.get(negatedId)!.push(delta); + } + } + } + + // Function to determine if a delta is effectively negated + const isEffectivelyNegated = (deltaId: DeltaID, visited: Set = new Set()): boolean => { + // Avoid infinite recursion in case of cycles + if (visited.has(deltaId)) { + return false; // If we've seen this delta before, assume it's not negated to break the cycle + } + + // Check if we've already determined this delta's status + if (deltaStatus.has(deltaId)) { + return deltaStatus.get(deltaId)!; + } + + // Get all negations targeting this delta + const negations = deltaToNegations.get(deltaId) || []; + + // If there are no negations, the delta is not negated + if (negations.length === 0) { + deltaStatus.set(deltaId, false); + return false; + } + + // Check each negation to see if it's effectively applied + // A negation is effective if it's not itself negated + for (const negation of negations) { + // If the negation delta is not itself negated, then the target is negated + if (!isEffectivelyNegated(negation.id, new Set([...visited, deltaId]))) { + deltaStatus.set(deltaId, true); + return true; + } + } + + // If all negations are themselves negated, the delta is not negated + deltaStatus.set(deltaId, false); + return false; + }; + + // Second pass: filter out effectively negated deltas and all negation deltas return deltas.filter(delta => { - // Exclude negation deltas themselves (they're metadata) + // Always exclude negation deltas (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}`); + // Check if this delta is effectively negated + const isNegated = isEffectivelyNegated(delta.id); + + if (isNegated) { + debug(`Filtering out effectively negated delta ${delta.id}`); return false; } @@ -128,36 +230,157 @@ export class NegationHelper { negationDeltas: number; negatedDeltas: number; effectiveDeltas: number; - negatedDeltaIds: DeltaID[]; - negationMap: Map; // negated -> [negating deltas] + negationsByProperty: { [key: string]: { negated: number; total: number } }; + negatedDeltaIds: string[]; + negationMap: Map; } { const negationDeltas = deltas.filter(d => this.isNegationDelta(d)); - const negatedDeltaIds = new Set(); const negationMap = new Map(); + const deltaById = new Map(); + const properties = new Set(); + const negatedDeltaIds = new Set(); - for (const negDelta of negationDeltas) { - const negatedId = this.getNegatedDeltaId(negDelta); - if (negatedId) { - negatedDeltaIds.add(negatedId); - - if (!negationMap.has(negatedId)) { - negationMap.set(negatedId, []); + // Build maps and collect properties + for (const delta of deltas) { + deltaById.set(delta.id, delta); + + // Collect all properties referenced in the delta + if (delta.pointers) { + for (const pointer of delta.pointers) { + if (pointer.targetContext) { + properties.add(pointer.targetContext); + } + } + } + + if (this.isNegationDelta(delta)) { + const negatedId = this.getNegatedDeltaId(delta); + if (negatedId) { + if (!negationMap.has(negatedId)) { + negationMap.set(negatedId, []); + } + negationMap.get(negatedId)!.push(delta.id); } - negationMap.get(negatedId)!.push(negDelta.id); } } - const effectiveDeltas = deltas.length - negationDeltas.length - negatedDeltaIds.size; + // Track which deltas are effectively negated + const deltaStatus = new Map(); + + // Function to determine if a delta is effectively negated + const isEffectivelyNegated = (deltaId: DeltaID, visited: Set = new Set()): boolean => { + // Avoid infinite recursion in case of cycles + if (visited.has(deltaId)) { + return false; // If we've seen this delta before, assume it's not negated to break the cycle + } + + // Check if we've already determined this delta's status + if (deltaStatus.has(deltaId)) { + return deltaStatus.get(deltaId)!; + } + + // Get all negations targeting this delta + const negations = negationMap.get(deltaId) || []; + + // If there are no negations, the delta is not negated + if (negations.length === 0) { + deltaStatus.set(deltaId, false); + return false; + } + + // Check each negation to see if it's effectively applied + // A negation is effective if it's not itself negated + for (const negationId of negations) { + // If the negation delta is not itself negated, then the target is negated + if (!isEffectivelyNegated(negationId, new Set([...visited, deltaId]))) { + deltaStatus.set(deltaId, true); + return true; + } + } + + // If all negations are themselves negated, the delta is not negated + deltaStatus.set(deltaId, false); + return false; + }; + + // First pass: determine status of all deltas + for (const delta of deltas) { + isEffectivelyNegated(delta.id); + } + + // Calculate statistics + let effectiveDeltas = 0; + const negationsByProperty: { [key: string]: { negated: number; total: number } } = {}; + + // Initialize property counters + for (const prop of properties) { + negationsByProperty[prop] = { negated: 0, total: 0 }; + } + + // Second pass: count negated and effective deltas + for (const delta of deltas) { + const isNegation = this.isNegationDelta(delta); + const isNegated = deltaStatus.get(delta.id) || false; + + if (isNegated) { + // For non-negation deltas, add them to the negated set + if (!isNegation) { + negatedDeltaIds.add(delta.id); + } else { + // For negation deltas, add the delta they negate (if it's not a negation delta) + const negatedId = this.getNegatedDeltaId(delta); + if (negatedId) { + const negatedDelta = deltaById.get(negatedId); + if (negatedDelta && !this.isNegationDelta(negatedDelta)) { + negatedDeltaIds.add(negatedId); + } + } + } + } + + if (!isNegation) { + if (isNegated) { + // Already counted in negatedDeltaIds + } else { + effectiveDeltas++; + } + } + } + + // Update property-based statistics + for (const delta of deltas) { + const isNegated = deltaStatus.get(delta.id) || false; + + if (delta.pointers) { + for (const pointer of delta.pointers) { + if (pointer.targetContext && negationsByProperty[pointer.targetContext] !== undefined) { + negationsByProperty[pointer.targetContext].total++; + if (isNegated) { + negationsByProperty[pointer.targetContext].negated++; + } + } + } + } + } return { totalDeltas: deltas.length, negationDeltas: negationDeltas.length, negatedDeltas: negatedDeltaIds.size, effectiveDeltas, + negationsByProperty, negatedDeltaIds: Array.from(negatedDeltaIds), negationMap }; } + + /** + * Helper to check if a delta with the given ID is a negation delta + */ + private static isNegationDeltaById(deltaId: DeltaID, deltas: Delta[]): boolean { + const delta = deltas.find(d => d.id === deltaId); + return delta ? this.isNegationDelta(delta) : false; + } /** * Apply negations to a delta stream in chronological order