import: phones match by edit distance, over fixtures a book apart
The fixture household's numbers were consecutive (900101, 900102,
900103), which made an edit distance over phones offer every Boole
for every other. The numbers are spread out instead, and a phone is
matched by Levenshtein like the name and the email. The recorded
exchanges' etags move with the cards.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vb8rg9WqWhQesDht1LL6t
change
commit a33fe642b4c8e91eb1da073e2b9f1af7fd0b3964
author Claude <noreply@anthropic.com>
date
parent bb729447
diff --git a/docs/plans/2026-09-23-merging-on-import.md b/docs/plans/2026-09-23-merging-on-import.md
index 0989ae4..7ee36ba 100644
--- a/docs/plans/2026-09-23-merging-on-import.md
+++ b/docs/plans/2026-09-23-merging-on-import.md
@@ -31,15 +31,16 @@ card over the three things a person is recognised by:
   "mary@example.com", which offered the fixture book's Ada for a card
   that was Mary's. The same handle at two providers is still one
   person.
-- **Each phone number**, as its last ten digits, and only when the two
-  agree. A country code or a trunk prefix on one side and not the other
-  is still one number, but a number one digit off is another line
-  rather than a typo of this one: the fixture household's numbers are
-  consecutive, and an edit distance offered every Boole for every
-  other.
-
-A name or an email is scored as the share of the longer string an edit
-leaves alone, and a phone as 1 or 0. A contact is offered when the best
+- **Each phone number**, by Levenshtein distance over its last ten
+  digits. A country code or a trunk prefix on one side and not the
+  other is still one number, and a digit mistyped is one edit in ten.
+  The fixture book's numbers were consecutive — a household at 900101,
+  900102 and 900103 — which offered every Boole for every other, so
+  they are spread out now rather than the distance given up; a real
+  book's numbers are not a sequence.
+
+Each is scored as the share of the longer string an edit leaves alone.
+A contact is offered when the best
 of any one field clears the threshold — not a sum or a mean. The cases
 worth catching are one field agreeing and the others absent or
 different: the same email under a nickname, the same number under a
diff --git a/lib/pro_tacts/import/match.rb b/lib/pro_tacts/import/match.rb
index 76fe1b0..9aebbc4 100644
--- a/lib/pro_tacts/import/match.rb
+++ b/lib/pro_tacts/import/match.rb
@@ -7,14 +7,12 @@ module ProTacts
     # ones it could update instead of arriving new
     # (docs/plans/2026-09-23-merging-on-import.md).
     #
-    # The three things a person is recognised by — the name, an email
-    # address, a phone number — because what an import meets is the
-    # same person spelled a little differently: a typo, a middle
-    # initial, a dot in an address. Edit distance for the name and
-    # the email; a phone number has to be the same number, its country
-    # code and punctuation aside, because one digit off is a different
-    # line rather than a typo of this one, and a household's lines are
-    # often a digit apart. A contact is offered when any one of the
+    # Edit distance over the three things a person is recognised by —
+    # the name, an email address, a phone number — because what an
+    # import meets is the same person spelled a little differently: a
+    # typo, a middle initial, a dot in an address, a number written
+    # with its country code on one side and without on the other. A
+    # contact is offered when any one of the
     # three comes close enough — the same email under a nickname is
     # the case worth catching, and a sum would bury it — and the
     # offers are ranked by all three, so the one agreeing on more
@@ -55,7 +53,7 @@ module ProTacts
         [
           closest(names(arriving), names(stored)),
           closest(emails(arriving), emails(stored)),
-          phones(arriving).intersect?(phones(stored)) ? 1.0 : 0.0,
+          closest(phones(arriving), phones(stored)),
         ]
       end
 
diff --git a/test/fixtures/cards/household-alicia.vcf b/test/fixtures/cards/household-alicia.vcf
index 791bf6f..6b15070 100644
--- a/test/fixtures/cards/household-alicia.vcf
+++ b/test/fixtures/cards/household-alicia.vcf
@@ -3,7 +3,7 @@ VERSION:3.0
 N:Boole;Alicia;;;
 FN:Alicia Boole
 EMAIL:alicia@example.com
-TEL;TYPE=cell:+44 7700 900103
+TEL;TYPE=cell:+44 7700 900735
 BDAY:1860-06-08
 UID:household-alicia
 END:VCARD
diff --git a/test/fixtures/cards/household-george.vcf b/test/fixtures/cards/household-george.vcf
index c385494..d56a5a0 100644
--- a/test/fixtures/cards/household-george.vcf
+++ b/test/fixtures/cards/household-george.vcf
@@ -3,7 +3,7 @@ VERSION:3.0
 N:Boole;George;;;
 FN:George Boole
 EMAIL:george@example.com
-TEL;TYPE=cell:+44 7700 900101
+TEL;TYPE=cell:+44 7700 900461
 BDAY:1815-11-02
 UID:household-george
 END:VCARD
diff --git a/test/fixtures/cards/household-mary.vcf b/test/fixtures/cards/household-mary.vcf
index 782624e..7afbd6f 100644
--- a/test/fixtures/cards/household-mary.vcf
+++ b/test/fixtures/cards/household-mary.vcf
@@ -4,7 +4,7 @@ N:Boole;Mary;Everest;;
 FN:Mary Everest Boole
 NICKNAME:Mrs B
 EMAIL:mary@example.com
-TEL;TYPE=cell:+44 7700 900102
+TEL;TYPE=cell:+44 7700 900218
 BDAY:1832-03-11
 UID:household-mary
 END:VCARD
diff --git a/test/fixtures/cards/phone-label.vcf b/test/fixtures/cards/phone-label.vcf
index d317c7d..acb40bd 100644
--- a/test/fixtures/cards/phone-label.vcf
+++ b/test/fixtures/cards/phone-label.vcf
@@ -4,7 +4,7 @@ N:Hopper;Grace;;;
 FN:Grace Hopper
 item1.TEL;type=CELL:+1-555-0177
 item1.X-ABLabel:Google Voice
-TEL;TYPE=home:+1-555-0101
+TEL;TYPE=home:+1-555-0142
 EMAIL:grace@example.com
 UID:phone-label
 END:VCARD
diff --git a/test/fixtures/cards/unsynced-pierre.vcf b/test/fixtures/cards/unsynced-pierre.vcf
index 1eb6ab7..19d7c53 100644
--- a/test/fixtures/cards/unsynced-pierre.vcf
+++ b/test/fixtures/cards/unsynced-pierre.vcf
@@ -3,7 +3,7 @@ VERSION:3.0
 N:Curie;Pierre;;;
 FN:Pierre Curie
 EMAIL:pierre@example.com
-TEL;TYPE=cell:+33 6 12 34 56 02
+TEL;TYPE=cell:+33 6 48 27 19 53
 BDAY:1859-05-15
 UID:unsynced-pierre
 END:VCARD
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 21285bb..8313cd5 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -150,7 +150,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-alicia.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
+        <d:getetag>"400976f14e4190967319e89b0ba65c8b164f3f821d376fc72b26a91c1c1c30b3"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -159,7 +159,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-george.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
+        <d:getetag>"08cb40c43b096ddd99a96f65b9f14ebc0ee0d0bfec50db3c5835f19c3c2d3c8c"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -168,7 +168,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-mary.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
+        <d:getetag>"e3e6ee61a5fab7bc63b6fb6892b1e3543390d0436ca855a29d9dca8ea7a8393c"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -195,7 +195,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/phone-label.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"255b9ca1ae879c7bc8d2d201d20b451f81934360fd5fc739b7af37c87e2c692f"</d:getetag>
+        <d:getetag>"d4d6f7201122bb9383bfac1dd0f2e55bd0a01a59c0c1007ea9e36580539fd24d"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 17e7be3..c43aeeb 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -115,7 +115,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-alicia.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
+        <d:getetag>"400976f14e4190967319e89b0ba65c8b164f3f821d376fc72b26a91c1c1c30b3"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -124,7 +124,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-george.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
+        <d:getetag>"08cb40c43b096ddd99a96f65b9f14ebc0ee0d0bfec50db3c5835f19c3c2d3c8c"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -133,7 +133,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-mary.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
+        <d:getetag>"e3e6ee61a5fab7bc63b6fb6892b1e3543390d0436ca855a29d9dca8ea7a8393c"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -160,7 +160,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/phone-label.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"255b9ca1ae879c7bc8d2d201d20b451f81934360fd5fc739b7af37c87e2c692f"</d:getetag>
+        <d:getetag>"d4d6f7201122bb9383bfac1dd0f2e55bd0a01a59c0c1007ea9e36580539fd24d"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 21285bb..8313cd5 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -150,7 +150,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-alicia.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
+        <d:getetag>"400976f14e4190967319e89b0ba65c8b164f3f821d376fc72b26a91c1c1c30b3"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -159,7 +159,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-george.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
+        <d:getetag>"08cb40c43b096ddd99a96f65b9f14ebc0ee0d0bfec50db3c5835f19c3c2d3c8c"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -168,7 +168,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-mary.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
+        <d:getetag>"e3e6ee61a5fab7bc63b6fb6892b1e3543390d0436ca855a29d9dca8ea7a8393c"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -195,7 +195,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/phone-label.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"255b9ca1ae879c7bc8d2d201d20b451f81934360fd5fc739b7af37c87e2c692f"</d:getetag>
+        <d:getetag>"d4d6f7201122bb9383bfac1dd0f2e55bd0a01a59c0c1007ea9e36580539fd24d"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
diff --git a/test/pro_tacts/import/test_match.rb b/test/pro_tacts/import/test_match.rb
index 4e4b6a2..acb73cf 100644
--- a/test/pro_tacts/import/test_match.rb
+++ b/test/pro_tacts/import/test_match.rb
@@ -61,12 +61,12 @@ class ImportMatchTest < Minitest::Test
     assert_equal [mother], Match.candidates(arriving, [mother])
   end
 
-  # One digit off is another line: a household's numbers are often
-  # consecutive.
-  def test_a_phone_number_a_digit_off_is_not_the_same_number
-    arriving = contact("0", "Mom", "TEL:+44 7700 900101")
+  # A digit mistyped is one edit in ten.
+  def test_a_phone_number_a_digit_off_is_offered
+    arriving = contact("0", "Mom", "TEL:+44 7700 900461")
+    george = contact("george", "George Boole", "TEL:+44 7700 900467")
 
-    assert_empty Match.candidates(arriving, [contact("alicia", "Alicia Boole", "TEL:+44 7700 900103")])
+    assert_equal [george], Match.candidates(arriving, [george, contact("mary", "Mary Boole", "TEL:+44 7700 900218")])
   end
 
   def test_nothing_alike_is_nothing_offered