diff --git a/src/git_paoding/core/diffatoms.py b/src/git_paoding/core/diffatoms.py index c8dc5ab..98a566b 100644 --- a/src/git_paoding/core/diffatoms.py +++ b/src/git_paoding/core/diffatoms.py @@ -1 +1,155 @@ -"""Diff atom construction.""" +"""Construct persistent atom metadata and ephemeral replay payloads.""" + +from __future__ import annotations + +import hashlib +from collections import defaultdict +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +from git_paoding.core.model import Atom, AtomKind, AtomState +from git_paoding.gitio.diffparse import RawDiffHunk + +_PREVIEW_LINE_LIMIT = 3 + + +@dataclass(frozen=True, slots=True) +class ReplayAtom: + """An atom paired with the full text payload needed for replay. + + ``Atom`` remains the compact persistent and contract-facing metadata type. + A ``ReplayAtom`` is deliberately ephemeral: callers reconstruct it from the + authoritative Base-to-Final diff whenever they need to replay content. + """ + + atom: Atom + removed_lines: tuple[bytes, ...] + added_lines: tuple[bytes, ...] + + +def _line_bytes(line: str) -> bytes: + return line.encode("utf-8", errors="surrogateescape") + + +def _hash_fields(fields: Iterable[bytes]) -> str: + """Hash length-delimited fields without concatenation ambiguities.""" + + digest = hashlib.sha256() + for field in fields: + digest.update(len(field).to_bytes(8, byteorder="big")) + digest.update(field) + return digest.hexdigest() + + +def _content_hash(hunk: RawDiffHunk) -> str: + fields = [b"removed"] + fields.extend(_line_bytes(line) for line in hunk.removed_lines) + fields.append(b"added") + fields.extend(_line_bytes(line) for line in hunk.added_lines) + return _hash_fields(fields) + + +def _atom_id_digest( + *, + path: str, + base_start: int, + base_len: int, + gap_seq: int, + content_hash: str, +) -> str: + return _hash_fields( + ( + path.encode("utf-8", errors="surrogateescape"), + str(base_start).encode("ascii"), + str(base_len).encode("ascii"), + str(gap_seq).encode("ascii"), + content_hash.encode("ascii"), + ) + ) + + +def _kind(hunk: RawDiffHunk) -> AtomKind: + if hunk.is_binary or hunk.is_mode_change or hunk.is_symlink: + return AtomKind.WHOLE_FILE + if hunk.is_add_file: + return AtomKind.ADD_FILE + if hunk.is_delete_file: + return AtomKind.DELETE_FILE + return AtomKind.MODIFY + + +def _preview(hunk: RawDiffHunk) -> str: + def safe_line(prefix: str, line: str) -> str: + raw_line = _line_bytes(line) + return prefix + raw_line.decode("utf-8", errors="replace") + + changed_lines = [safe_line("-", line) for line in hunk.removed_lines] + changed_lines.extend(safe_line("+", line) for line in hunk.added_lines) + preview = "".join(changed_lines[:_PREVIEW_LINE_LIMIT]) + if len(changed_lines) > _PREVIEW_LINE_LIMIT: + if preview and not preview.endswith("\n"): + preview += "\n" + preview += "…" + return preview + + +def atomize_hunks(hunks: Sequence[RawDiffHunk]) -> tuple[ReplayAtom, ...]: + """Convert raw hunks to atoms plus non-persistent text replay payloads. + + Pure insertions sharing a Base gap receive monotonically increasing + ``gap_seq`` values in their Final/diff order. Atom IDs use A7's first-eight + hash form and receive deterministic ``-N`` suffixes on collisions. + """ + + gap_counts: dict[tuple[str, int], int] = defaultdict(int) + id_counts: dict[str, int] = defaultdict(int) + replay_atoms: list[ReplayAtom] = [] + + for hunk in hunks: + gap_seq = 0 + if hunk.base_len == 0: + gap_key = (hunk.path, hunk.base_start) + gap_seq = gap_counts[gap_key] + gap_counts[gap_key] += 1 + + content_hash = _content_hash(hunk) + short_id = _atom_id_digest( + path=hunk.path, + base_start=hunk.base_start, + base_len=hunk.base_len, + gap_seq=gap_seq, + content_hash=content_hash, + )[:8] + id_counts[short_id] += 1 + collision_number = id_counts[short_id] + atom_id = short_id if collision_number == 1 else f"{short_id}-{collision_number}" + + atom = Atom( + atom_id=atom_id, + path=hunk.path, + kind=_kind(hunk), + base_start=hunk.base_start, + base_len=hunk.base_len, + final_start=hunk.final_start, + final_len=hunk.final_len, + gap_seq=gap_seq, + content_hash=content_hash, + owner=None, + state=AtomState.UNASSIGNED, + preview=_preview(hunk), + ) + replay_atoms.append( + ReplayAtom( + atom=atom, + removed_lines=tuple(_line_bytes(line) for line in hunk.removed_lines), + added_lines=tuple(_line_bytes(line) for line in hunk.added_lines), + ) + ) + + return tuple(replay_atoms) + + +def build_atoms(hunks: Sequence[RawDiffHunk]) -> tuple[Atom, ...]: + """Convert raw hunks to compact persistent atoms.""" + + return tuple(replay_atom.atom for replay_atom in atomize_hunks(hunks)) diff --git a/src/git_paoding/core/projection.py b/src/git_paoding/core/projection.py index b1d7592..cd6906c 100644 --- a/src/git_paoding/core/projection.py +++ b/src/git_paoding/core/projection.py @@ -1 +1,104 @@ -"""Slice projection construction.""" +"""Pure replay primitives used by slice projection construction.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from git_paoding.core.diffatoms import ReplayAtom +from git_paoding.core.model import AtomKind, PaodingError + + +class ReplayError(PaodingError): + """Raised when Base-anchored text atoms cannot be replayed safely.""" + + +def _base_index(replay_atom: ReplayAtom) -> int: + atom = replay_atom.atom + return atom.base_start if atom.base_len == 0 else atom.base_start - 1 + + +def _application_key(replay_atom: ReplayAtom) -> tuple[int, int, int]: + """Order edits for stable in-place splicing against Base coordinates. + + Higher Base positions run first. At the same list index, replacements run + before insertions, and shared-gap insertions run in reverse ``gap_seq`` so + repeated insertion at one index yields their ascending Final order. + """ + + atom = replay_atom.atom + return (_base_index(replay_atom), int(atom.base_len > 0), atom.gap_seq) + + +def replay_file( + base_content: bytes | None, + replay_atoms: Sequence[ReplayAtom], +) -> bytes | None: + """Replay a selected set of Base-anchored text atoms onto one Base file. + + ``None`` represents a missing file, allowing text add/delete atoms to use + the same primitive. Whole-file atoms are intentionally rejected here: + binary data, modes, and symlinks are applied by the tree/blob projection + layer rather than pretending they are line-oriented edits. + """ + + if not replay_atoms: + return base_content + + paths = {replay_atom.atom.path for replay_atom in replay_atoms} + if len(paths) != 1: + raise ReplayError("replay_file accepts atoms for exactly one path") + + whole_file_ids = [ + replay_atom.atom.atom_id + for replay_atom in replay_atoms + if replay_atom.atom.kind is AtomKind.WHOLE_FILE + ] + if whole_file_ids: + joined_ids = ", ".join(whole_file_ids) + raise ReplayError(f"whole-file atoms require tree/blob replay: {joined_ids}") + + if base_content is None: + invalid = [ + replay_atom.atom.atom_id + for replay_atom in replay_atoms + if replay_atom.atom.kind is not AtomKind.ADD_FILE + ] + if invalid: + raise ReplayError("only add-file atoms can be replayed onto a missing Base file") + lines: list[bytes] = [] + else: + lines = base_content.splitlines(keepends=True) + + replacement_indexes: set[int] = set() + deletes_file = False + creates_file = False + for replay_atom in sorted(replay_atoms, key=_application_key, reverse=True): + atom = replay_atom.atom + index = _base_index(replay_atom) + if index < 0 or index > len(lines): + raise ReplayError(f"atom {atom.atom_id} has an out-of-range Base anchor") + + if atom.base_len > 0: + if index in replacement_indexes: + raise ReplayError(f"atoms overlap at Base index {index}") + replacement_indexes.add(index) + end = index + atom.base_len + if end > len(lines): + raise ReplayError(f"atom {atom.atom_id} extends past Base content") + actual_removed = tuple(lines[index:end]) + if actual_removed != replay_atom.removed_lines: + raise ReplayError(f"atom {atom.atom_id} does not match Base content") + lines[index:end] = replay_atom.added_lines + else: + if replay_atom.removed_lines: + raise ReplayError(f"insertion atom {atom.atom_id} unexpectedly removes content") + lines[index:index] = replay_atom.added_lines + + deletes_file = deletes_file or atom.kind is AtomKind.DELETE_FILE + creates_file = creates_file or atom.kind is AtomKind.ADD_FILE + + if deletes_file: + if creates_file or lines: + raise ReplayError("delete-file replay did not produce a missing file") + return None + return b"".join(lines) diff --git a/tests/integration/test_diffatoms_real_git.py b/tests/integration/test_diffatoms_real_git.py new file mode 100644 index 0000000..d1b3b48 --- /dev/null +++ b/tests/integration/test_diffatoms_real_git.py @@ -0,0 +1,43 @@ +"""Atom construction checked against real Git diff output.""" + +from __future__ import annotations + +import re + +import pytest + +from conftest import RepoFile, ScratchRepoFactory +from git_paoding.core.diffatoms import build_atoms +from git_paoding.core.model import AtomKind +from git_paoding.gitio.diffparse import diff_trees + + +@pytest.mark.integration +def test_real_diff_confirms_rename_whole_file_and_atom_id_assumptions( + scratch_repo_factory: ScratchRepoFactory, +) -> None: + repo = scratch_repo_factory( + { + "old-name.txt": "moved\n", + "binary.bin": b"\x00before", + "script.sh": "echo hi\n", + "link": RepoFile("old-target", symlink=True), + }, + { + "new-name.txt": "moved\n", + "binary.bin": b"\x00after", + "script.sh": RepoFile("echo hi\n", executable=True), + "link": RepoFile("new-target", symlink=True), + }, + ) + + atoms = build_atoms(diff_trees(repo.path, repo.base_oid, repo.final_oid)) + by_path = {atom.path: atom for atom in atoms} + + assert by_path["old-name.txt"].kind is AtomKind.DELETE_FILE + assert by_path["new-name.txt"].kind is AtomKind.ADD_FILE + assert by_path["binary.bin"].kind is AtomKind.WHOLE_FILE + assert by_path["script.sh"].kind is AtomKind.WHOLE_FILE + assert by_path["link"].kind is AtomKind.WHOLE_FILE + assert all(re.fullmatch(r"[0-9a-f]{8}(?:-\d+)?", atom.atom_id) for atom in atoms) + assert len({atom.atom_id for atom in atoms}) == len(atoms) diff --git a/tests/unit/test_diffatoms.py b/tests/unit/test_diffatoms.py new file mode 100644 index 0000000..6e77d69 --- /dev/null +++ b/tests/unit/test_diffatoms.py @@ -0,0 +1,148 @@ +"""Table-driven tests for raw-hunk to atom construction.""" + +from __future__ import annotations + +import re + +import pytest + +from git_paoding.core.diffatoms import atomize_hunks, build_atoms +from git_paoding.core.model import AtomKind, AtomState +from git_paoding.gitio.diffparse import RawDiffHunk + + +def _hunk( + *, + path: str = "example.txt", + base_start: int = 1, + base_len: int = 1, + final_start: int = 1, + final_len: int = 1, + removed: tuple[str, ...] = ("before\n",), + added: tuple[str, ...] = ("after\n",), + is_add_file: bool = False, + is_delete_file: bool = False, + is_binary: bool = False, + is_mode_change: bool = False, + is_symlink: bool = False, +) -> RawDiffHunk: + return RawDiffHunk( + path=path, + base_start=base_start, + base_len=base_len, + final_start=final_start, + final_len=final_len, + removed_lines=removed, + added_lines=added, + is_add_file=is_add_file, + is_delete_file=is_delete_file, + is_binary=is_binary, + is_mode_change=is_mode_change, + is_symlink=is_symlink, + ) + + +@pytest.mark.unit +def test_atomize_hunks_orders_insertions_at_the_same_base_gap() -> None: + hunks = ( + _hunk( + base_start=0, + base_len=0, + final_start=1, + removed=(), + added=("first\n",), + ), + _hunk( + base_start=0, + base_len=0, + final_start=2, + removed=(), + added=("second\n",), + ), + _hunk( + base_start=2, + base_len=0, + final_start=5, + removed=(), + added=("end\n",), + ), + ) + + replay_atoms = atomize_hunks(hunks) + + assert [item.atom.gap_seq for item in replay_atoms] == [0, 1, 0] + assert len({item.atom.atom_id for item in replay_atoms}) == 3 + assert all(item.atom.state is AtomState.UNASSIGNED for item in replay_atoms) + assert all(item.atom.owner is None for item in replay_atoms) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ( + "is_add_file", + "is_delete_file", + "is_binary", + "is_mode_change", + "is_symlink", + "expected_kind", + ), + [ + (False, False, False, False, False, AtomKind.MODIFY), + (True, False, False, False, False, AtomKind.ADD_FILE), + (False, True, False, False, False, AtomKind.DELETE_FILE), + (False, False, True, False, False, AtomKind.WHOLE_FILE), + (False, False, False, True, False, AtomKind.WHOLE_FILE), + (False, False, False, False, True, AtomKind.WHOLE_FILE), + (True, False, False, False, True, AtomKind.WHOLE_FILE), + ], +) +def test_build_atoms_classifies_text_and_whole_file_changes( + is_add_file: bool, + is_delete_file: bool, + is_binary: bool, + is_mode_change: bool, + is_symlink: bool, + expected_kind: AtomKind, +) -> None: + atom = build_atoms( + ( + _hunk( + is_add_file=is_add_file, + is_delete_file=is_delete_file, + is_binary=is_binary, + is_mode_change=is_mode_change, + is_symlink=is_symlink, + ), + ) + )[0] + + assert atom.kind is expected_kind + assert re.fullmatch(r"[0-9a-f]{8}", atom.atom_id) + assert len(atom.content_hash) == 64 + + +@pytest.mark.unit +def test_build_atoms_adds_a_deterministic_collision_suffix() -> None: + duplicate = _hunk() + + atoms = build_atoms((duplicate, duplicate, duplicate)) + + assert atoms[1].atom_id == f"{atoms[0].atom_id}-2" + assert atoms[2].atom_id == f"{atoms[0].atom_id}-3" + + +@pytest.mark.unit +def test_replay_payload_preserves_unterminated_and_non_utf8_lines() -> None: + removed = b"before-\xff" + added = b"after-\xfe" + hunk = _hunk( + removed=(removed.decode("utf-8", errors="surrogateescape"),), + added=(added.decode("utf-8", errors="surrogateescape"),), + ) + + replay_atom = atomize_hunks((hunk,))[0] + + assert replay_atom.removed_lines == (removed,) + assert replay_atom.added_lines == (added,) + assert "�" in replay_atom.atom.preview + replay_atom.atom.model_dump_json() diff --git a/tests/unit/test_replay.py b/tests/unit/test_replay.py new file mode 100644 index 0000000..fe526b6 --- /dev/null +++ b/tests/unit/test_replay.py @@ -0,0 +1,223 @@ +"""Table-driven tests for the Base-anchored text replay primitive.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import pytest + +from git_paoding.core.diffatoms import ReplayAtom, atomize_hunks +from git_paoding.core.projection import ReplayError, replay_file +from git_paoding.gitio.diffparse import RawDiffHunk + + +def _hunk( + *, + base_start: int, + base_len: int, + final_start: int, + final_len: int, + removed: tuple[str, ...] = (), + added: tuple[str, ...] = (), + path: str = "example.txt", + is_add_file: bool = False, + is_delete_file: bool = False, + is_binary: bool = False, +) -> RawDiffHunk: + return RawDiffHunk( + path=path, + base_start=base_start, + base_len=base_len, + final_start=final_start, + final_len=final_len, + removed_lines=removed, + added_lines=added, + is_add_file=is_add_file, + is_delete_file=is_delete_file, + is_binary=is_binary, + ) + + +def _replay(base: bytes | None, hunks: Sequence[RawDiffHunk]) -> bytes | None: + return replay_file(base, atomize_hunks(hunks)) + + +@pytest.mark.unit +def test_replay_interleaved_changes_in_one_file() -> None: + base = b"one\ntwo\nthree\nfour\nfive\n" + hunks = ( + _hunk( + base_start=1, + base_len=1, + final_start=1, + final_len=1, + removed=("one\n",), + added=("ONE\n",), + ), + _hunk( + base_start=3, + base_len=1, + final_start=3, + final_len=1, + removed=("three\n",), + added=("THREE\n",), + ), + _hunk( + base_start=5, + base_len=1, + final_start=5, + final_len=1, + removed=("five\n",), + added=("FIVE\n",), + ), + ) + + assert _replay(base, hunks) == b"ONE\ntwo\nTHREE\nfour\nFIVE\n" + + +@pytest.mark.unit +def test_replay_adjacent_hunks_without_offset_drift() -> None: + base = b"one\ntwo\nthree\nfour\n" + hunks = ( + _hunk( + base_start=2, + base_len=1, + final_start=2, + final_len=2, + removed=("two\n",), + added=("TWO-A\n", "TWO-B\n"), + ), + _hunk( + base_start=3, + base_len=1, + final_start=4, + final_len=0, + removed=("three\n",), + ), + ) + + assert _replay(base, hunks) == b"one\nTWO-A\nTWO-B\nfour\n" + + +@pytest.mark.unit +def test_replay_insertions_at_start_end_and_a_shared_gap() -> None: + base = b"middle\n" + hunks = ( + _hunk( + base_start=0, + base_len=0, + final_start=1, + final_len=1, + added=("start-1\n",), + ), + _hunk( + base_start=0, + base_len=0, + final_start=2, + final_len=1, + added=("start-2\n",), + ), + _hunk( + base_start=1, + base_len=0, + final_start=4, + final_len=1, + added=("end\n",), + ), + ) + + assert _replay(base, hunks) == b"start-1\nstart-2\nmiddle\nend\n" + + +@pytest.mark.unit +def test_replay_insertion_and_replacement_at_the_same_list_index() -> None: + base = b"one\ntwo\n" + hunks = ( + _hunk( + base_start=1, + base_len=0, + final_start=2, + final_len=1, + added=("between\n",), + ), + _hunk( + base_start=2, + base_len=1, + final_start=3, + final_len=1, + removed=("two\n",), + added=("TWO\n",), + ), + ) + + assert _replay(base, hunks) == b"one\nbetween\nTWO\n" + + +@pytest.mark.unit +def test_replay_text_file_creation_and_deletion() -> None: + create = _hunk( + base_start=0, + base_len=0, + final_start=1, + final_len=2, + added=("new\n", "file\n"), + is_add_file=True, + ) + delete = _hunk( + base_start=1, + base_len=2, + final_start=0, + final_len=0, + removed=("old\n", "file\n"), + is_delete_file=True, + ) + + assert _replay(None, (create,)) == b"new\nfile\n" + assert _replay(b"old\nfile\n", (delete,)) is None + + +@pytest.mark.unit +def test_replay_preserves_missing_newline_at_eof_byte_for_byte() -> None: + hunk = _hunk( + base_start=1, + base_len=1, + final_start=1, + final_len=1, + removed=("before",), + added=("after",), + ) + + assert _replay(b"before", (hunk,)) == b"after" + + +@pytest.mark.unit +def test_replay_rejects_whole_file_atoms() -> None: + whole_file: ReplayAtom = atomize_hunks( + ( + _hunk( + base_start=0, + base_len=0, + final_start=0, + final_len=0, + is_binary=True, + ), + ) + )[0] + + with pytest.raises(ReplayError, match="tree/blob replay"): + replay_file(b"\x00before", (whole_file,)) + + +@pytest.mark.unit +def test_replay_rejects_payload_that_does_not_match_base() -> None: + hunk = _hunk( + base_start=1, + base_len=1, + final_start=1, + final_len=1, + removed=("not-base\n",), + added=("after\n",), + ) + + with pytest.raises(ReplayError, match="does not match Base content"): + _replay(b"base\n", (hunk,)) diff --git a/tests/unit/test_replay_property.py b/tests/unit/test_replay_property.py new file mode 100644 index 0000000..9821ca6 --- /dev/null +++ b/tests/unit/test_replay_property.py @@ -0,0 +1,151 @@ +"""Property checks for partitioned Base-anchored replay.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest +from hypothesis import assume, given, settings +from hypothesis import strategies as st +from hypothesis.strategies import DataObject + +from git_paoding.core.diffatoms import atomize_hunks +from git_paoding.core.model import AtomState +from git_paoding.core.projection import replay_file +from git_paoding.gitio.diffparse import RawDiffHunk + +_OWNERS = ("slice-a", "slice-b", "slice-c") +_LINE_TOKEN = st.text( + alphabet=st.characters(whitelist_categories=("Ll", "Lu", "Nd")), + min_size=1, + max_size=8, +) + + +def _raw_hunk( + *, + base_start: int, + base_len: int, + final_start: int, + final_len: int, + removed: tuple[str, ...] = (), + added: tuple[str, ...] = (), +) -> RawDiffHunk: + return RawDiffHunk( + path="property.txt", + base_start=base_start, + base_len=base_len, + final_start=final_start, + final_len=final_len, + removed_lines=removed, + added_lines=added, + ) + + +@pytest.mark.unit +@settings(max_examples=200, deadline=None, derandomize=True) +@given(line_count=st.integers(min_value=1, max_value=8), data=st.data()) +def test_partitioned_replay_reconstructs_final_for_every_slice( + line_count: int, data: DataObject +) -> None: + base_tokens = data.draw( + st.lists(_LINE_TOKEN, min_size=line_count, max_size=line_count), + label="Base file lines", + ) + base_lines = [f"base-{index}-{token}\n" for index, token in enumerate(base_tokens)] + actions = data.draw( + st.lists( + st.tuples(st.sampled_from(("keep", "replace", "delete")), _LINE_TOKEN), + min_size=line_count, + max_size=line_count, + ), + label="base-line actions", + ) + gap_insertions = data.draw( + st.lists( + st.lists(_LINE_TOKEN, min_size=0, max_size=2), + min_size=line_count + 1, + max_size=line_count + 1, + ), + label="gap insertions", + ) + + final_lines: list[str] = [] + hunks: list[RawDiffHunk] = [] + final_line_count = 0 + for gap in range(line_count + 1): + for insertion_number, token in enumerate(gap_insertions[gap]): + added = f"insert-{gap}-{insertion_number}-{token}\n" + hunks.append( + _raw_hunk( + base_start=gap, + base_len=0, + final_start=final_line_count + 1, + final_len=1, + added=(added,), + ) + ) + final_lines.append(added) + final_line_count += 1 + + if gap == line_count: + continue + action, token = actions[gap] + base_line = base_lines[gap] + if action == "keep": + final_lines.append(base_line) + final_line_count += 1 + elif action == "replace": + added = f"replacement-{gap}-{token}\n" + hunks.append( + _raw_hunk( + base_start=gap + 1, + base_len=1, + final_start=final_line_count + 1, + final_len=1, + removed=(base_line,), + added=(added,), + ) + ) + final_lines.append(added) + final_line_count += 1 + else: + hunks.append( + _raw_hunk( + base_start=gap + 1, + base_len=1, + final_start=final_line_count, + final_len=0, + removed=(base_line,), + ) + ) + + assume(hunks) + replay_atoms = atomize_hunks(hunks) + owners = data.draw( + st.lists( + st.sampled_from(_OWNERS), + min_size=len(replay_atoms), + max_size=len(replay_atoms), + ), + label="atom ownership", + ) + owned_atoms = tuple( + replace( + replay_atom, + atom=replay_atom.atom.model_copy(update={"owner": owner, "state": AtomState.ASSIGNED}), + ) + for replay_atom, owner in zip(replay_atoms, owners, strict=True) + ) + + base = "".join(base_lines).encode() + final = "".join(final_lines).encode() + assert replay_file(base, owned_atoms) == final + + for owner in set(owners): + non_owner_atoms = tuple(item for item in owned_atoms if item.atom.owner != owner) + owner_atoms = tuple(item for item in owned_atoms if item.atom.owner == owner) + + synthetic_base = replay_file(base, non_owner_atoms) + assert synthetic_base is not None + assert replay_file(base, (*non_owner_atoms, *owner_atoms)) == final