اگر یک راهنمای مهندسی از دو مسیر وارد مجموعهٔ جست‌وجو شود، لازم است بدانیم «تکراری» دقیقاً چه معنایی دارد. آیا همهٔ بایت‌های فایل برابرند، متن برابر است یا فقط موضوع مشترک است؟ این درس تعریف محدودی انتخاب می‌کند: برابری کاملِ نمایش نسخه‌دارِ زبان، عنوان و بدنه. سپس با SHA-256 نامزدهای مقایسه را پیدا و بایت‌های کامل را بررسی می‌کنیم. کد هیچ سندی را حذف نمی‌کند و هیچ نمایه یا سامانهٔ واقعی را تغییر نمی‌دهد.

۱. برابری محتوای سند را پیش از ساخت هش تعریف کنیم

تفاوت تکرار بایت و برابری سند طبق قرارداد
تصویر مفهومی: تفاوت تکرار بایت و برابری سند طبق قرارداد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

سه رکورد ساختگی A، B و C داریم. A و B عنوان «راهنمای حسگر» و بدنهٔ «ولتاژ برحسب V است.» را دارند، اما شناسه و مرجع منشأ آن‌ها متفاوت است. در C، واحدِ نوشته‌شده در بدنه mV است. طبق قرارداد این درس، A و B یک گروه محتوایی و C گروه دیگری می‌سازند. تشابه موضوع حسگر باعث یکی شدن V و mV نمی‌شود؛ متن دقیق حفظ می‌شود.

هر ورودی دقیقاً doc_id، source_ref، language، title و body دارد. شناسه و مرجع منشأ برای ردیابی نگه داشته می‌شوند، ولی جزو تعریف محتوای برابر نیستند. زبان نیز در برابری شرکت می‌کند؛ یک برچسب متفاوت، حتی با متن یکسان، گروه جدا می‌سازد. این انتخاب برای آموزش است و باید پیش از استفاده روی مجموعهٔ واقعی با نیاز آن مجموعه هماهنگ شود.

این برنامه فایل PDF، HTML یا متن خام شبکه را نمی‌خواند؛ دیکشنریِ از قبل آماده‌شده می‌گیرد. استخراج عنوان و بدنه مسئولیت مرحلهٔ قبلی است. برای اتصال به بازیابی، درس نمایهٔ معکوس مرحلهٔ پیدا کردن سندها از واژه‌ها را نشان می‌دهد. تشخیص برابریِ محتوای ورودی، قرارداد مستقلی از تطبیق واژهٔ پرس‌وجو است.

۲. نمایش ثابتِ بایت را با نسخهٔ قرارداد ثبت کنیم

نمایش ثابت و نسخه‌دار متن برای مقایسه
تصویر مفهومی: نمایش ثابت و نسخه‌دار متن برای مقایسه؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

نمایش مقایسه یک آرایهٔ JSON با ترتیب ثابت است: نام قرارداد doc-content-v1، زبان، عنوان و بدنه. خروجی بدون فاصلهٔ قالب‌بندیِ اضافی، با حفظ نویسه‌های غیرلاتین و سپس UTF-8 تولید می‌شود. ترتیبِ کلیدهای دیکشنری ورودی بر این آرایه اثر نمی‌گذارد. نسخهٔ قرارداد داخل همان بایت‌هاست؛ تغییر تعریف برابری در آینده باید نسخه و گروه‌بندی تازه داشته باشد.

چسباندن بی‌مرزِ عنوان و بدنه کافی نیست: عنوان ab با بدنه c و عنوان a با بدنه bc هر دو رشتهٔ abc می‌دهند. آرایهٔ JSON مرز این دو فیلد و escapeهای لازم را حفظ می‌کند. مستندات JSON پایتون قالب‌بندی و سریال‌سازی رشته را توضیح می‌دهد. اینجا عدد شناور، فیلد دلخواه یا ساختار تودرتوی عمومی در محتوای مقایسه نداریم.

RFC 8785 برای JCS قواعد دقیقِ نمایش عدد، ترتیب نام ویژگی‌ها و UTF-8 دارد و رشتهٔ یونیکد را بدون نرمال‌سازی نگه می‌دارد. آرایهٔ محدود ما پیاده‌سازی JCS معرفی نمی‌شود. در سیاست خودمان نیز فاصلهٔ پایانی، بزرگی حرف، شکل پایان خط و تفاوت «ی» و «ي» حفظ می‌شوند؛ دو رشتهٔ ظاهراً نزدیک ممکن است بایت متفاوت داشته باشند. هیچ پاک‌سازی پنهانیِ متن برای رسیدن به تعداد کمتر انجام نمی‌دهیم.

۳. هش را برای نامزدسازی و برابری کامل را برای تصمیم به کار ببریم

مقایسهٔ کامل محتوا پس از یافتن نامزد هم‌هش
تصویر مفهومی: مقایسهٔ کامل محتوا پس از یافتن نامزد هم‌هش؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

hashlib پایتون ورودی بایتی را به SHA-256 می‌دهد و hexdigest نمایش شانزده‌شانزدهی آن را برمی‌گرداند. کد برای هر digest، فهرستی از گروه‌های نامزد نگه می‌دارد. هنگام ورود سند تازه، علاوه بر digest، تمام بایت‌های نمایش محتوا با هر نامزد مقایسه می‌شود. فقط برابری آن بایت‌ها باعث پیوستن به گروه قبلی است.

برای آزمایش مسیر برخورد، تابع digest را در یک اجرای کنترل‌شده طوری جایگزین می‌کنیم که برای هر ورودی شصت‌وچهار صفر بدهد. با وجود همسان شدن همهٔ digestها، A و B با هم و C جدا می‌مانند. این تزریق یک وابستگی آزمایشی است؛ برخورد واقعی در SHA-256 کشف نکرده‌ایم. هدف، آزمودن منطق گروه‌بندی وقتی نامزدها از هم تفکیک نمی‌شوند است.

هش به‌تنهایی اصالت سند، صاحب اثر یا معتبر بودن ادعای فنی را ثابت نمی‌کند. همچنین دو رکورد هم‌محتوا ممکن است مرجع، مجوز دسترسی یا وضعیت اعتبار متفاوتی داشته باشند. انتخاب مرجع و نسخهٔ معتبر در RAG مسئلهٔ دیگری را بررسی می‌کند. گروه محتواییِ این درس مجوز ادغام آن تصمیم‌ها یا نمایش محتوای همهٔ منشأها به یک کاربر نیست.

۴. کد کامل، پاسخ دستی و هجده بررسی اجراشده

حفظ شناسه و منشأ هر نسخهٔ ورودی
تصویر مفهومی: حفظ شناسه و منشأ هر نسخهٔ ورودی؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل exact_document_dedup.py با کتابخانهٔ استاندارد و Python 3.14.5 اجرا شده است. پاسخ دستی دو گروه است: نمایندهٔ A با اعضای A و B و سپس نمایندهٔ C. انتخاب نماینده بر پایهٔ شناسهٔ مرتب‌شده است؛ هیچ ارزیابیِ تازگی، کیفیت یا اعتبار منبع در انتخاب A انجام نشده است. جابه‌جا کردن ترتیب ورودی نباید این پاسخ را تغییر دهد.

"""Offline exact content grouping under an authored versioned serialization contract.

No JSON parser, JCS implementation, text normalization, source deletion or index update.
"""
import hashlib
import json
import platform
import re
import unicodedata

CONTRACT = 'doc-content-v1'


def identity(value):
    if type(value) is not str or re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}',value) is None:
        raise ValueError('identity')
    return value


def canonical_content(record):
    if type(record) is not dict or set(record)!={'doc_id','source_ref','language','title','body'}:
        raise ValueError('fields')
    identity(record['doc_id'])
    identity(record['source_ref'])
    if type(record['language']) is not str or record['language'] not in ('fa','en'):
        raise ValueError('language')
    for name,maximum in (('title',80),('body',4096)):
        if type(record[name]) is not str or not 1<=len(record[name])<=maximum:
            raise ValueError(name)
    # An explicit fixed-order content tuple; IDs/provenance are deliberately outside equality.
    text=json.dumps([CONTRACT,record['language'],record['title'],record['body']],
                    ensure_ascii=False,separators=(',',':'))
    try:
        return text.encode('utf-8')
    except UnicodeEncodeError as error:
        raise ValueError('unicode') from error


def sha256_hex(payload):
    return hashlib.sha256(payload).hexdigest()


def group_documents(records,digest=sha256_hex):
    if type(records) not in (list,tuple) or not 1<=len(records)<=128:
        raise ValueError('records')
    prepared=[]
    seen=set()
    for record in records:
        content=canonical_content(record)
        doc_id=record['doc_id']
        if doc_id in seen:
            raise ValueError('duplicate_doc_id')
        seen.add(doc_id)
        candidate=digest(content)
        if type(candidate) is not str or re.fullmatch('[0-9a-f]{64}',candidate) is None:
            raise ValueError('digest_contract')
        prepared.append((doc_id,record['source_ref'],content,candidate))
    buckets={}
    groups=[]
    for doc_id,source_ref,content,candidate in sorted(prepared):
        bucket=buckets.setdefault(candidate,[])
        group=next((item for item in bucket if item['content']==content),None)
        if group is None:
            group={'content':content,'representative':doc_id,'candidate_digest':candidate,'members':[]}
            bucket.append(group)
            groups.append(group)
        group['members'].append({'doc_id':doc_id,'source_ref':source_ref})
    return [{'representative':g['representative'],'candidate_digest':g['candidate_digest'],
             'canonical_utf8_length':len(g['content']),'members':g['members']} for g in groups]


def fails(call,message):
    try:
        call()
    except ValueError as error:
        return str(error)==message
    return False


def run_example():
    a={'doc_id':'A','source_ref':'Ref1','language':'fa','title':'راهنمای حسگر','body':'ولتاژ برحسب V است.'}
    b=dict(a,doc_id='B',source_ref='Ref2')
    c=dict(a,doc_id='C',source_ref='Ref3',body='ولتاژ برحسب mV است.')
    records=[a,b,c]
    before=json.dumps(records,ensure_ascii=False)
    observed=group_documents(records)
    hand_bytes='["doc-content-v1","fa","راهنمای حسگر","ولتاژ برحسب V است."]'.encode('utf-8')
    groups_for=lambda changes:group_documents([a,dict(a,doc_id='B',source_ref='Ref2',**changes)])
    newline_left=dict(a,body='الف\nب')
    newline_right=dict(b,body='الف\r\nب')
    nfc=dict(a,body='café')
    nfd=dict(b,body='cafe\u0301')
    forced=group_documents(records,digest=lambda payload:'0'*64)
    boundary_left=dict(a,title='ab',body='c')
    boundary_right=dict(b,title='a',body='bc')
    mutable=dict(a)
    saved=group_documents([mutable])
    mutable['source_ref']='ChangedRef'
    checks={
        'fixed_order_content_bytes_equal_independent_literal_utf8_oracle':canonical_content(a)==hand_bytes,
        'sha256_function_matches_independent_published_abc_known_answer':sha256_hex(b'abc')=='ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' and len(sha256_hex(hand_bytes))==64,
        'hand_groups_are_A_with_B_and_separate_C_without_deleting_inputs':[(g['representative'],[x['doc_id'] for x in g['members']]) for g in observed]==[('A',['A','B']),('C',['C'])] and json.dumps(records,ensure_ascii=False)==before,
        'input_record_order_does_not_change_representatives_or_groups':group_documents([c,b,a])==observed,
        'different_ids_and_source_refs_preserved_in_same_content_group':observed[0]['members']==[{'doc_id':'A','source_ref':'Ref1'},{'doc_id':'B','source_ref':'Ref2'}],
        'changed_title_is_a_distinct_document_content':len(groups_for({'title':'راهنمای موتور'}))==2,
        'changed_body_punctuation_or_unit_is_not_exact_duplicate':len(groups_for({'body':a['body']+'!'}))==2 and len(observed)==2,
        'language_tag_participates_in_content_identity':len(groups_for({'language':'en'}))==2,
        'line_ending_lf_and_crlf_are_preserved_and_distinct':len(group_documents([newline_left,newline_right]))==2 and canonical_content(newline_left)!=canonical_content(newline_right),
        'unicode_canonical_equivalence_is_not_implicitly_normalized':unicodedata.normalize('NFC',nfc['body'])==unicodedata.normalize('NFC',nfd['body']) and len(group_documents([nfc,nfd]))==2,
        'spaces_case_and_persian_arabic_letter_variants_remain_distinct':all(len(groups_for({'body':value}))==2 for value in (a['body']+' ',a['body'].replace('V','v'))) and len(groups_for({'title':a['title'].replace('ی','ي')}))==2,
        'length_delimited_json_tuple_avoids_naive_concatenation_ambiguity':boundary_left['title']+boundary_left['body']==boundary_right['title']+boundary_right['body'] and len(group_documents([boundary_left,boundary_right]))==2,
        'forced_candidate_hash_collision_still_compares_full_content_bytes':len(forced)==2 and all(g['candidate_digest']=='0'*64 for g in forced) and [[m['doc_id'] for m in g['members']] for g in forced]==[['A','B'],['C']],
        'digest_dependency_contract_rejects_nonhex_nonstring_wrong_length':all(fails(lambda value=value:group_documents([a],digest=lambda data:value),'digest_contract') for value in ('x'*64,'a'*63,None,True,'A'*64)),
        'duplicate_ids_invalid_identity_and_record_container_are_rejected':fails(lambda:group_documents([a,a]),'duplicate_doc_id') and fails(lambda:group_documents([]),'records') and fails(lambda:group_documents([a]*129),'records') and fails(lambda:group_documents(iter([a])),'records') and all(fails(lambda value=value:group_documents([dict(a,doc_id=value)]),'identity') for value in ('',1,'1bad','آ','A'*33)),
        'schema_unknown_fields_nonstring_and_surrogate_are_rejected':fails(lambda:group_documents([dict(a,extra='x')]),'fields') and fails(lambda:group_documents([{'doc_id':'A'}]),'fields') and fails(lambda:group_documents([dict(a,body=True)]),'body') and fails(lambda:group_documents([dict(a,body='\ud800')]),'unicode'),
        'declared_title_body_and_language_bounds_are_explicit':len(canonical_content(dict(a,title='t'*80,body='b'*4096)))>0 and all(fails(lambda value=value:canonical_content(dict(a,title=value)),'title') for value in ('','t'*81)) and all(fails(lambda value=value:canonical_content(dict(a,body=value)),'body') for value in ('','b'*4097)) and all(fails(lambda value=value:canonical_content(dict(a,language=value)),'language') for value in ('FA','',None,1)),
        'returned_provenance_snapshot_does_not_follow_caller_mutation':saved[0]['members']==[{'doc_id':'A','source_ref':'Ref1'}] and mutable['source_ref']=='ChangedRef',
    }
    assert len(checks)==18 and all(type(value)is bool and value for value in checks.values()),checks
    return {'python':platform.python_version(),'dependencies':'standard library only',
            'scope':'Exact grouping under authored doc-content-v1 tuple; SHA256 candidate buckets with full byte equality; no JCS, semantic equivalence, signature, source deletion, authorization or real document ingestion',
            'contract':CONTRACT,'hand_canonical_utf8_hex':hand_bytes.hex(),
            'canonical_content_sha256':sha256_hex(hand_bytes),'groups':observed,
            'forced_collision_groups':forced,'checks':checks}


if __name__=='__main__':
    print(json.dumps(run_example(),ensure_ascii=False,indent=2,allow_nan=False))

مرجع بایت را جدا از تابع سریال‌سازی، به شکل یک رشتهٔ JSON ثابت نوشته‌ایم؛ خروجی UTF-8 سند A در این قرارداد ۸۳ بایت دارد. یک بررسی دیگر، پاسخ شناخته‌شدهٔ SHA-256 برای abc را از نمونهٔ رسمی NIST کنترل می‌کند. این بررسی کوچک، گواهی اعتبارسنجی رمزنگاری یا بازتولید تمام جزئیات الگوریتم نیست.

{
  "python": "3.14.5",
  "dependencies": "standard library only",
  "scope": "Exact grouping under authored doc-content-v1 tuple; SHA256 candidate buckets with full byte equality; no JCS, semantic equivalence, signature, source deletion, authorization or real document ingestion",
  "contract": "doc-content-v1",
  "hand_canonical_utf8_hex": "5b22646f632d636f6e74656e742d7631222c226661222c22d8b1d8a7d987d986d985d8a7db8c20d8add8b3daafd8b1222c22d988d984d8aad8a7da9820d8a8d8b1d8add8b3d8a8205620d8a7d8b3d8aa2e225d",
  "canonical_content_sha256": "ccc7ab9029274f98953a87f389d0562af696c08eba476c76691abcf087c7c187",
  "groups": [
    {
      "representative": "A",
      "candidate_digest": "ccc7ab9029274f98953a87f389d0562af696c08eba476c76691abcf087c7c187",
      "canonical_utf8_length": 83,
      "members": [
        {
          "doc_id": "A",
          "source_ref": "Ref1"
        },
        {
          "doc_id": "B",
          "source_ref": "Ref2"
        }
      ]
    },
    {
      "representative": "C",
      "candidate_digest": "22ed458d4896c336eb41e817568693cb84e4e16e5ff774b4036cea444d6861d8",
      "canonical_utf8_length": 84,
      "members": [
        {
          "doc_id": "C",
          "source_ref": "Ref3"
        }
      ]
    }
  ],
  "forced_collision_groups": [
    {
      "representative": "A",
      "candidate_digest": "0000000000000000000000000000000000000000000000000000000000000000",
      "canonical_utf8_length": 83,
      "members": [
        {
          "doc_id": "A",
          "source_ref": "Ref1"
        },
        {
          "doc_id": "B",
          "source_ref": "Ref2"
        }
      ]
    },
    {
      "representative": "C",
      "candidate_digest": "0000000000000000000000000000000000000000000000000000000000000000",
      "canonical_utf8_length": 84,
      "members": [
        {
          "doc_id": "C",
          "source_ref": "Ref3"
        }
      ]
    }
  ],
  "checks": {
    "fixed_order_content_bytes_equal_independent_literal_utf8_oracle": true,
    "sha256_function_matches_independent_published_abc_known_answer": true,
    "hand_groups_are_A_with_B_and_separate_C_without_deleting_inputs": true,
    "input_record_order_does_not_change_representatives_or_groups": true,
    "different_ids_and_source_refs_preserved_in_same_content_group": true,
    "changed_title_is_a_distinct_document_content": true,
    "changed_body_punctuation_or_unit_is_not_exact_duplicate": true,
    "language_tag_participates_in_content_identity": true,
    "line_ending_lf_and_crlf_are_preserved_and_distinct": true,
    "unicode_canonical_equivalence_is_not_implicitly_normalized": true,
    "spaces_case_and_persian_arabic_letter_variants_remain_distinct": true,
    "length_delimited_json_tuple_avoids_naive_concatenation_ambiguity": true,
    "forced_candidate_hash_collision_still_compares_full_content_bytes": true,
    "digest_dependency_contract_rejects_nonhex_nonstring_wrong_length": true,
    "duplicate_ids_invalid_identity_and_record_container_are_rejected": true,
    "schema_unknown_fields_nonstring_and_surrogate_are_rejected": true,
    "declared_title_body_and_language_bounds_are_explicit": true,
    "returned_provenance_snapshot_does_not_follow_caller_mutation": true
  }
}

آزمون‌ها تغییر عنوان، بدنه، زبان، واحد و پایان خط را بررسی می‌کنند. در نمونهٔ café، شکل ترکیبی و شکل دارای علامت جداگانه می‌توانند با NFC هم‌ارز شوند، اما کد اصلی این تبدیل را انجام نمی‌دهد و آن دو را جدا نگه می‌دارد. نرمال‌سازی فقط در پرسشِ مرجعِ همان آزمون استفاده شده است. هجده نام، هجده گروه بررسی هستند؛ اجرای دوباره تعداد تازه‌ای به آن‌ها اضافه نمی‌کند.

قرارداد اندازه هم روشن است: یک تا ۱۲۸ رکورد، شناسه‌های لاتینِ یکتا با حداکثر ۳۲ نویسه، زبان fa یا en، عنوان یک تا هشتاد و بدنه یک تا ۴۰۹۶ نویسه. نوع غیررشته‌ای و surrogate تنها که UTF-8 معتبر نمی‌سازد رد می‌شوند. این سقف‌ها اندازهٔ سامانهٔ واقعی، مجوز پردازش ورودی نامطمئن یا بودجهٔ دقیق حافظه را مشخص نمی‌کنند.

۵. چهار پرسش پاسخ‌دار برای نگهداری امنِ معنای سند

حدود حذف تکرار دقیق و متن‌های نزدیک
تصویر مفهومی: حدود حذف تکرار دقیق و متن‌های نزدیک؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: آیا برابر بودن digest برای یکی دانستن دو سند کافی است؟ پاسخ: در این برنامه خیر؛ digest نامزد می‌سازد و بایت کامل تصمیم می‌گیرد. آزمون digest ثابت نشان می‌دهد متن متفاوت حتی در یک سبد نامزد هم گروه مستقل می‌ماند.

پرسش دوم: چرا Ref1 و Ref2 پس از گروه‌بندی باقی می‌مانند؟ پاسخ: برابری متن، منشأهای ورود را یکی نمی‌کند. فهرست اعضا هر دو شناسه و مرجع را نگه می‌دارد. این فهرست جای پشتیبان فایل اصلی، زمان ورود یا سامانهٔ نگهداری همهٔ فراداده‌ها نیست.

پرسش سوم: آیا تغییر پایان خط از LF به CRLF تکراری تشخیص داده می‌شود؟ پاسخ: طبق قرارداد نسخهٔ یک خیر؛ شکل دقیق رشته حفظ شده است. اگر کاربرد به یکسان‌سازی پایان خط نیاز دارد، آن سیاست باید آشکار، نسخه‌دار و همراه آزمونِ نمونه‌های ادغام‌شده تعریف شود.

پرسش چهارم: آیا بعد از عبور آزمون‌ها می‌توان نسخه‌های هم‌گروه را حذف کرد؟ پاسخ: این کد فقط گروه‌بندی پیشنهاد می‌کند و هیچ حذف یا به‌روزرسانی نمایه ندارد. تصمیم نگهداری به نیاز ردیابی، مجوز و نسخه‌بندی جدا وابسته است. منابع در ۲۴ مهر ۱۴۰۵ خوانده شده‌اند؛ RFC 8785 سند اطلاعاتی ژوئن ۲۰۲۰ است و تاریخ دسترسی، خبر پژوهشی تازه یا نتیجهٔ عملیاتی محسوب نمی‌شود.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.