src/Comparison.bend source
src/Comparison.bend on the hub · documented module
import Baseimport ./Reading.bend as Readingimport ./LogicalKey.bend as LogicalKeyimport ./StateTree.bend as StateTreeimport ./History.bend as Historyimport ./Store.bend as Store# Structural tree diff, membership-based (no ordering decisions): added =# second-only keys, removed = first-only keys, modified = shared keys with# different value hashes. Zero Cmp matching keeps every branch provable.# The v0.3 fan-out splits entries alternately and compares halves in# parallel; counts and sets agree with the sequential path, order does not# (downstream merge sorts before hashing, so order never leaks into hashes).# One changed key with its before/after hashes.type ModifiedEntry is Data: Changed{changed_key: LogicalKey.LogicalKey, before_hash: String, after_hash: String}# Added, removed and modified sets plus prune/compare counts.type CompareResult is Data: Make{added_keys: List<&2, LogicalKey.LogicalKey>, removed_keys: List<&2, LogicalKey.LogicalKey>, modified_entries: List<&2, ModifiedEntry>, pruned_subtree_count: Nat, compared_entry_count: Nat}# Projects the added keys out of a compare result.def added_keys_of(compare_result: CompareResult) -> List<&2, LogicalKey.LogicalKey>: match compare_result: case Make{added_keys, removed_keys, modified_entries, pruned_subtree_count, compared_entry_count}: added_keys# Projects the removed keys out of a compare result.def removed_keys_of(compare_result: CompareResult) -> List<&2, LogicalKey.LogicalKey>: match compare_result: case Make{added_keys, removed_keys, modified_entries, pruned_subtree_count, compared_entry_count}: removed_keys# Projects the modified entries out of a compare result.def modified_entries_of(compare_result: CompareResult) -> List<&2, ModifiedEntry>: match compare_result: case Make{added_keys, removed_keys, modified_entries, pruned_subtree_count, compared_entry_count}: modified_entries# Projects the pruned-subtree count out of a compare result.def pruned_count_of(compare_result: CompareResult) -> Nat: match compare_result: case Make{added_keys, removed_keys, modified_entries, pruned_subtree_count, compared_entry_count}: pruned_subtree_count# Projects the compared-entry count out of a compare result.def compared_entry_count_of(compare_result: CompareResult) -> Nat: match compare_result: case Make{added_keys, removed_keys, modified_entries, pruned_subtree_count, compared_entry_count}: compared_entry_count# Splits an entry key into namespace and record.def entry_to_logical_key(head_entry: Reading.ChainEntry) -> LogicalKey.LogicalKey: LogicalKey.split_combined_key(Reading.entry_key_of(head_entry))# True short-circuits the membership scan.def decide_member_found(head_matches: Bool, recursive_result: Bool) -> Bool: match head_matches: case True{}: True{} case False{}: recursive_result# Scans entries for the target key.def key_in_entries(+target_key: String, entries: List<&2, Reading.ChainEntry>) -> Bool: match entries: case Nil{}: False{} case Con{head_entry, remaining_entries}: decide_member_found(String.eq(Reading.entry_key_of(head_entry), target_key), key_in_entries(target_key, remaining_entries))# Keeps the entry only when absent from the other side.def decide_member_keep(is_member: Bool, +head_entry: Reading.ChainEntry, tail_keys: List<&2, LogicalKey.LogicalKey>) -> List<&2, LogicalKey.LogicalKey>: match is_member: case True{}: tail_keys case False{}: Con{entry_to_logical_key(head_entry), tail_keys}# Keys of second absent from first: the added set.def second_only_entries(+first_entries: List<&2, Reading.ChainEntry>, second_entries: List<&2, Reading.ChainEntry>) -> List<&2, LogicalKey.LogicalKey>: match second_entries: case Nil{}: Nil{} case Con{+head_entry, remaining_entries}: decide_member_keep(key_in_entries(Reading.entry_key_of(head_entry), first_entries), head_entry, second_only_entries(first_entries, remaining_entries))# Keys of first absent from second: the removed set.def first_only_entries(+second_entries: List<&2, Reading.ChainEntry>, first_entries: List<&2, Reading.ChainEntry>) -> List<&2, LogicalKey.LogicalKey>: match first_entries: case Nil{}: Nil{} case Con{+head_entry, remaining_entries}: decide_member_keep(key_in_entries(Reading.entry_key_of(head_entry), second_entries), head_entry, first_only_entries(second_entries, remaining_entries))# Some on match, otherwise the tail result.def decide_value_found(head_matches: Bool, head_value: String, tail_result: Maybe<&2, String>) -> Maybe<&2, String>: match head_matches: case True{}: Some{head_value} case False{}: tail_result# Finds the value stored under the key.def value_of_key(+target_key: String, entries: List<&2, Reading.ChainEntry>) -> Maybe<&2, String>: match entries: case Nil{}: None{} case Con{+head_entry, remaining_entries}: decide_value_found(String.eq(Reading.entry_key_of(head_entry), target_key), Reading.entry_value_of(head_entry), value_of_key(target_key, remaining_entries))# Records a change only when values differ.def decide_modified_value(values_equal: Bool, +head_entry: Reading.ChainEntry, +second_value: String, tail_modified: List<&2, ModifiedEntry>) -> List<&2, ModifiedEntry>: match values_equal: case True{}: tail_modified case False{}: Con{Changed{entry_to_logical_key(head_entry), Reading.entry_value_of(head_entry), second_value}, tail_modified}# Records a change when the key exists on both sides.def decide_modified_member(second_value_maybe: Maybe<&2, String>, +head_entry: Reading.ChainEntry, tail_modified: List<&2, ModifiedEntry>) -> List<&2, ModifiedEntry>: match second_value_maybe: case None{}: tail_modified case Some{+second_value}: decide_modified_value(String.eq(Reading.entry_value_of(head_entry), second_value), head_entry, second_value, tail_modified)# Shared keys with different values: the modified set.def changed_pairs(+second_entries: List<&2, Reading.ChainEntry>, first_entries: List<&2, Reading.ChainEntry>) -> List<&2, ModifiedEntry>: match first_entries: case Nil{}: Nil{} case Con{+head_entry, remaining_entries}: decide_modified_member(value_of_key(Reading.entry_key_of(head_entry), second_entries), head_entry, changed_pairs(second_entries, remaining_entries))# Membership diff core; counts cover both inputs.def compare_sorted_entries(+first_entries: List<&2, Reading.ChainEntry>, +second_entries: List<&2, Reading.ChainEntry>) -> CompareResult: Make{second_only_entries(first_entries, second_entries), first_only_entries(second_entries, first_entries), changed_pairs(second_entries, first_entries), 0n, Nat.add(List.length(&2, Reading.ChainEntry, first_entries), List.length(&2, Reading.ChainEntry, second_entries))}# Alternates entries into even/odd accumulators, then reverses.def split_step(entries: List<&2, Reading.ChainEntry>, take_head: Bool, evens_accum: List<&2, Reading.ChainEntry>, odds_accum: List<&2, Reading.ChainEntry>) -> List<&2, Reading.ChainEntry> & List<&2, Reading.ChainEntry>: match entries: case Nil{}: (List.reverse(&2, Reading.ChainEntry, evens_accum), List.reverse(&2, Reading.ChainEntry, odds_accum)) case Con{head_entry, remaining_entries}: match take_head: case True{}: split_step(remaining_entries, False{}, Con{head_entry, evens_accum}, odds_accum) case False{}: split_step(remaining_entries, True{}, evens_accum, Con{head_entry, odds_accum})# Splits into balanced halves for any key distribution.def split_entries(+source_entries: List<&2, Reading.ChainEntry>) -> List<&2, Reading.ChainEntry> & List<&2, Reading.ChainEntry>: split_step(source_entries, True{}, Nil{}, Nil{})# Copies the spine so each lane scans a private list without atomics.def decide_linear_value(values_equal: Bool, +first_head: Reading.ChainEntry, +second_head: Reading.ChainEntry, +tail_diff: CompareResult) -> CompareResult: match values_equal: case True{}: Make{added_keys_of(tail_diff), removed_keys_of(tail_diff), modified_entries_of(tail_diff), pruned_count_of(tail_diff), Nat.add(2n, compared_entry_count_of(tail_diff))} case False{}: Make{added_keys_of(tail_diff), removed_keys_of(tail_diff), Con{Changed{entry_to_logical_key(first_head), Reading.entry_value_of(first_head), Reading.entry_value_of(second_head)}, modified_entries_of(tail_diff)}, pruned_count_of(tail_diff), Nat.add(2n, compared_entry_count_of(tail_diff))}# Merges one step by key order; both sides kept on ties (stable).# @unsafe: calls merge_by_key below (branch-dependent recursion needs the# forward reference); terminates in practice (one side always shrinks).@unsafedef merge_order(ordering: Cmp, first_head: Reading.ChainEntry, first_tail: List<&2, Reading.ChainEntry>, second_head: Reading.ChainEntry, second_tail: List<&2, Reading.ChainEntry>) -> List<&2, Reading.ChainEntry>: match ordering: case LT{}: Con{first_head, merge_by_key(first_tail, Con{second_head, second_tail})} case GT{}: Con{second_head, merge_by_key(Con{first_head, first_tail}, second_tail)} case EQ{}: Con{first_head, Con{second_head, merge_by_key(first_tail, second_tail)}}# Unpacks the key comparison into the ordering step.def merge_dispatch(cmp_result: (String & String) & Cmp, first_head: Reading.ChainEntry, first_tail: List<&2, Reading.ChainEntry>, second_head: Reading.ChainEntry, second_tail: List<&2, Reading.ChainEntry>) -> List<&2, Reading.ChainEntry>: match cmp_result: case ((returned_first, returned_second), ordering): merge_order(ordering, first_head, first_tail, second_head, second_tail)# Linear merge of two sorted lists by entry key.# @unsafe: the greater-side branch recurses on a reconstructed list, which# the termination checker cannot see through; one side always shrinks.@unsafedef merge_by_key(first_sorted: List<&2, Reading.ChainEntry>, second_sorted: List<&2, Reading.ChainEntry>) -> List<&2, Reading.ChainEntry>: match first_sorted: case Nil{}: second_sorted case Con{+first_head, first_tail}: match second_sorted: case Nil{}: Con{first_head, first_tail} case Con{+second_head, second_tail}: merge_dispatch(String.cmp(Reading.entry_key_of(first_head), Reading.entry_key_of(second_head)), first_head, first_tail, second_head, second_tail)# Merges the sorted halves of one split pair.# @unsafe: calls sort_fuel below (split halves are computed, not structural).@unsafedef sort_split_halves(split_pair: List<&2, Reading.ChainEntry> & List<&2, Reading.ChainEntry>, +remaining_fuel: Nat) -> List<&2, Reading.ChainEntry>: match split_pair: case (even_entries, odd_entries): merge_by_key(sort_fuel(remaining_fuel, even_entries), sort_fuel(remaining_fuel, odd_entries))# Merge sort with Nat fuel; fuel bounds recursion depth, not size.def sort_fuel(remaining_fuel: Nat, unsorted_entries: List<&2, Reading.ChainEntry>) -> List<&2, Reading.ChainEntry>: match remaining_fuel: case 0n: unsorted_entries case 1n+fuel_left: match unsorted_entries: case Nil{}: Nil{} case Con{single_entry, Nil{}}: Con{single_entry, Nil{}} case Con{first_entry, Con{second_entry, remaining_entries}}: sort_split_halves(split_entries(unsorted_entries), fuel_left)# Sorts entries by key; fuel starts at the entry count.def sort_entries_by_key(+unsorted_entries: List<&2, Reading.ChainEntry>) -> List<&2, Reading.ChainEntry>: sort_fuel(List.length(&2, Reading.ChainEntry, unsorted_entries), unsorted_entries)# Decides one linear-diff step by key order.# @unsafe: calls merge_sorted_diff below (branch-dependent recursion needs# the forward reference); one side always shrinks.@unsafedef decide_linear_order(ordering: Cmp, +first_head: Reading.ChainEntry, first_tail: List<&2, Reading.ChainEntry>, +second_head: Reading.ChainEntry, second_tail: List<&2, Reading.ChainEntry>) -> CompareResult: match ordering: case LT{}: prepend_linear_removed(first_head, merge_sorted_diff(first_tail, Con{second_head, second_tail})) case GT{}: prepend_linear_added(second_head, merge_sorted_diff(Con{first_head, first_tail}, second_tail)) case EQ{}: decide_linear_value(String.eq(Reading.entry_value_of(first_head), Reading.entry_value_of(second_head)), first_head, second_head, merge_sorted_diff(first_tail, second_tail))# Prepends a removed key to a computed tail diff; one entry consumed.def prepend_linear_removed(+head_entry: Reading.ChainEntry, +tail_diff: CompareResult) -> CompareResult: Make{added_keys_of(tail_diff), Con{entry_to_logical_key(head_entry), removed_keys_of(tail_diff)}, modified_entries_of(tail_diff), pruned_count_of(tail_diff), Nat.add(1n, compared_entry_count_of(tail_diff))}# Prepends an added key to a computed tail diff; one entry consumed.def prepend_linear_added(+head_entry: Reading.ChainEntry, +tail_diff: CompareResult) -> CompareResult: Make{Con{entry_to_logical_key(head_entry), added_keys_of(tail_diff)}, removed_keys_of(tail_diff), modified_entries_of(tail_diff), pruned_count_of(tail_diff), Nat.add(1n, compared_entry_count_of(tail_diff))}# Unpacks the key comparison into the linear-diff step.def dispatch_linear_diff(cmp_result: (String & String) & Cmp, +first_head: Reading.ChainEntry, first_tail: List<&2, Reading.ChainEntry>, +second_head: Reading.ChainEntry, second_tail: List<&2, Reading.ChainEntry>) -> CompareResult: match cmp_result: case ((returned_first, returned_second), ordering): decide_linear_order(ordering, first_head, first_tail, second_head, second_tail)# Decides one linear-diff step over sorted heads.def linear_diff_heads(+first_head: Reading.ChainEntry, first_tail: List<&2, Reading.ChainEntry>, +second_head: Reading.ChainEntry, second_tail: List<&2, Reading.ChainEntry>) -> CompareResult: dispatch_linear_diff(String.cmp(Reading.entry_key_of(first_head), Reading.entry_key_of(second_head)), first_head, first_tail, second_head, second_tail)# Linear diff over two sorted lists; counts cover both inputs.def merge_sorted_diff(+first_sorted: List<&2, Reading.ChainEntry>, +second_sorted: List<&2, Reading.ChainEntry>) -> CompareResult: match first_sorted: case Nil{}: Make{second_only_entries(Nil{}, second_sorted), Nil{}, Nil{}, 0n, List.length(&2, Reading.ChainEntry, second_sorted)} case Con{first_head, first_tail}: match second_sorted: case Nil{}: Make{Nil{}, first_only_entries(Nil{}, Con{first_head, first_tail}), Nil{}, 0n, List.length(&2, Reading.ChainEntry, Con{first_head, first_tail})} case Con{second_head, second_tail}: linear_diff_heads(first_head, first_tail, second_head, second_tail)# Sorted-path diff: sorts both lists, then linear-diffs.def compare_entries_sorted(+first_entries: List<&2, Reading.ChainEntry>, +second_entries: List<&2, Reading.ChainEntry>) -> CompareResult: merge_sorted_diff(sort_entries_by_key(first_entries), sort_entries_by_key(second_entries))# Routes every compare through the sorted path; fastest at all sizes.def compare_entries_auto(+first_entries: List<&2, Reading.ChainEntry>, +second_entries: List<&2, Reading.ChainEntry>) -> CompareResult: compare_entries_sorted(first_entries, second_entries)# Missing trees read as empty entry lists.def entries_or_empty(tree_maybe: Maybe<&2, StateTree.StateTreeNode>) -> List<&2, Reading.ChainEntry>: match tree_maybe: case None{}: Nil{} case Some{found_tree}: StateTree.tree_entries(found_tree)# Missing trees hash as the empty string.def hash_or_empty(tree_maybe: Maybe<&2, StateTree.StateTreeNode>) -> String: match tree_maybe: case None{}: "" case Some{found_tree}: StateTree.tree_hash_of(found_tree)# Equal hashes yield an empty delta with one prune counted.def pruned_result(first_entries: List<&2, Reading.ChainEntry>, second_entries: List<&2, Reading.ChainEntry>) -> CompareResult: Make{Nil{}, Nil{}, Nil{}, 1n, Nat.add(List.length(&2, Reading.ChainEntry, first_entries), List.length(&2, Reading.ChainEntry, second_entries))}# Missing second tree degrades to a one-sided diff.def pruned_or_present(second_maybe: Maybe<&2, StateTree.StateTreeNode>, first_entries: List<&2, Reading.ChainEntry>) -> CompareResult: match second_maybe: case None{}: compare_entries_auto(first_entries, Nil{}) case Some{second_tree}: pruned_result(first_entries, StateTree.tree_entries(second_tree))# Missing first tree degrades to a one-sided diff.def pruned_or_empty(first_maybe: Maybe<&2, StateTree.StateTreeNode>, second_maybe: Maybe<&2, StateTree.StateTreeNode>) -> CompareResult: match first_maybe: case None{}: compare_entries_auto(Nil{}, entries_or_empty(second_maybe)) case Some{first_tree}: pruned_or_present(second_maybe, StateTree.tree_entries(first_tree))# Equal hashes prune; different hashes diff.def dispatch_tree_hashes(hashes_equal: Bool, +first_maybe: Maybe<&2, StateTree.StateTreeNode>, +second_maybe: Maybe<&2, StateTree.StateTreeNode>) -> CompareResult: match hashes_equal: case True{}: pruned_or_empty(first_maybe, second_maybe) case False{}: compare_entries_auto(entries_or_empty(first_maybe), entries_or_empty(second_maybe))# Diffs two loaded trees with hash pruning.def compare_loaded_trees(+first_maybe: Maybe<&2, StateTree.StateTreeNode>, +second_maybe: Maybe<&2, StateTree.StateTreeNode>) -> CompareResult: dispatch_tree_hashes(String.eq(hash_or_empty(first_maybe), hash_or_empty(second_maybe)), first_maybe, second_maybe)# Shell: loads both trees and diffs them.def compare_commits(+first_identifier: String, +second_identifier: String) -> Store.Op<&2, CompareResult>: do Store.Op<&2, CompareResult>: first_tree_maybe : Maybe<&2, StateTree.StateTreeNode> <- History.read_tree_at(first_identifier) second_tree_maybe : Maybe<&2, StateTree.StateTreeNode> <- History.read_tree_at(second_identifier) return compare_loaded_trees(first_tree_maybe, second_tree_maybe)