sieve-referee 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """
2
+ sieve_referee
3
+ ~~~~~~~~~~~~~
4
+ Deterministic, zero-dependency plagiarism and text structure screening engine.
5
+ """
6
+ from .referee import SieveReferee
7
+ from .evaluator import batch_evaluate
8
+ from .models import EvaluationResult, SignalColor
9
+
10
+ __version__ = "1.0.0"
11
+ __all__ = ["SieveReferee", "batch_evaluate", "EvaluationResult", "SignalColor", "__version__"]
@@ -0,0 +1,39 @@
1
+ from typing import Dict, Set, Tuple
2
+ from collections import defaultdict
3
+ from sieve_referee.tokenizer import tokenize
4
+
5
+
6
+ class DeterministicBlocker:
7
+ def __init__(self, ngram_size: int = 3, min_shared_ngrams: int = 1):
8
+ self.ngram_size = ngram_size
9
+ self.min_shared_ngrams = min_shared_ngrams
10
+
11
+ def _extract_ngrams(self, text: str) -> Set[str]:
12
+ tokens = [t.raw_text for t in tokenize(text)]
13
+ if len(tokens) < self.ngram_size:
14
+ return set(tokens) if tokens else {text}
15
+ return {"".join(tokens[i : i + self.ngram_size]) for i in range(len(tokens) - self.ngram_size + 1)}
16
+
17
+ def get_candidate_pairs(self, docs: Dict[str, str]) -> Set[Tuple[str, str]]:
18
+ doc_ids = sorted(docs.keys())
19
+ if len(doc_ids) < 10:
20
+ candidates = set()
21
+ for i in range(len(doc_ids)):
22
+ for j in range(i + 1, len(doc_ids)):
23
+ candidates.add((doc_ids[i], doc_ids[j]))
24
+ return candidates
25
+ inverted_index: Dict[str, Set[str]] = defaultdict(set)
26
+ for doc_id in doc_ids:
27
+ ngrams = self._extract_ngrams(docs[doc_id])
28
+ for ngram in ngrams:
29
+ inverted_index[ngram].add(doc_id)
30
+ shared_counts = defaultdict(int)
31
+ for ngram, sharing_docs in inverted_index.items():
32
+ if len(sharing_docs) < 2:
33
+ continue
34
+ sorted_sharing = sorted(sharing_docs)
35
+ for i in range(len(sorted_sharing)):
36
+ for j in range(i + 1, len(sorted_sharing)):
37
+ pair = (sorted_sharing[i], sorted_sharing[j])
38
+ shared_counts[pair] += 1
39
+ return {pair for pair, count in shared_counts.items() if count >= self.min_shared_ngrams}
@@ -0,0 +1,29 @@
1
+ from typing import Dict, List, Set, Tuple
2
+ from .models import EvaluationResult
3
+
4
+
5
+ def detect_indirect_chains(results: List[EvaluationResult], clusters: Dict[str, List[str]]) -> List[Dict]:
6
+ direct_pairs: Set[Tuple[str, str]] = set()
7
+ for res in results:
8
+ sig = getattr(res, "evaluation_signal", "GREEN")
9
+ sig_val = sig.value if hasattr(sig, "value") else str(sig)
10
+ if sig_val in ["RED", "YELLOW"]:
11
+ u, v = sorted([res.item_id, res.matched_peer_id])
12
+ direct_pairs.add((u, v))
13
+
14
+ seen_clusters: Set[Tuple[str, ...]] = set()
15
+ unique_clusters: List[List[str]] = []
16
+ for members in clusters.values():
17
+ key = tuple(sorted(members))
18
+ if len(key) >= 3 and key not in seen_clusters:
19
+ seen_clusters.add(key)
20
+ unique_clusters.append(list(key))
21
+
22
+ indirect_chains: List[Dict] = []
23
+ for cluster in unique_clusters:
24
+ for i in range(len(cluster)):
25
+ for j in range(i + 1, len(cluster)):
26
+ u, v = sorted([cluster[i], cluster[j]])
27
+ if (u, v) not in direct_pairs:
28
+ indirect_chains.append({"item_id_a": u, "item_id_b": v, "cluster_members": cluster})
29
+ return indirect_chains
@@ -0,0 +1,15 @@
1
+ import re
2
+ from typing import List
3
+ from .models import Chunk
4
+
5
+
6
+ def split_into_multi_scale_windows(text: str, window_sizes: List[int] = [1, 2]) -> List[Chunk]:
7
+ sentences = [s.strip() + "。" for s in re.split(r"正?。|!|?|\n", text) if s.strip()]
8
+ chunks = []
9
+ chunk_id = 0
10
+ for w_size in window_sizes:
11
+ for i in range(len(sentences) - w_size + 1):
12
+ window = sentences[i : i + w_size]
13
+ chunks.append(Chunk(chunk_id=chunk_id, text="".join(window), window_size=w_size))
14
+ chunk_id += 1
15
+ return chunks
sieve_referee/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from .chain_detector import detect_indirect_chains
8
+ from .dashboard_builder import generate_dashboard_html, generate_standalone_dashboard_html
9
+ from .diff_builder import generate_diff_html
10
+ from .evaluator import batch_evaluate, build_clusters
11
+ from .io_utils import safe_read_text
12
+ from .models import EvaluationResult
13
+
14
+
15
+ def result_to_dict(res: EvaluationResult) -> dict:
16
+ signal_str = res.evaluation_signal.value if hasattr(res.evaluation_signal, "value") else str(res.evaluation_signal)
17
+ return {
18
+ "item_id": res.item_id,
19
+ "matched_peer_id": res.matched_peer_id,
20
+ "signal": signal_str,
21
+ "mask": res.mask,
22
+ "pattern_name": res.pattern_name,
23
+ "reason": res.reason,
24
+ }
25
+
26
+
27
+ def main():
28
+ parser = argparse.ArgumentParser(description="sieve_referee: Deterministic Text Plagiarism Screening Engine")
29
+ parser.add_argument("target_dir", type=str, help="Target directory containing .txt files")
30
+ parser.add_argument("--output-dir", type=str, default="reports")
31
+ parser.add_argument("--json", action="store_true")
32
+ parser.add_argument("--json-file", type=str, default=None)
33
+ parser.add_argument("--fail-on-risk", action="store_true")
34
+ parser.add_argument("-j", "--jobs", type=int, default=None)
35
+ parser.add_argument("--standalone", action="store_true")
36
+ args = parser.parse_args()
37
+
38
+ target_path = Path(args.target_dir)
39
+ if not target_path.exists() or not target_path.is_dir():
40
+ print(f"Error: Directory '{args.target_dir}' does not exist.", file=sys.stderr)
41
+ sys.exit(2)
42
+
43
+ doc_dict = {}
44
+ for p in target_path.glob("*.txt"):
45
+ content = safe_read_text(p)
46
+ if content:
47
+ doc_dict[p.name] = content
48
+
49
+ if len(doc_dict) < 2:
50
+ print("Error: At least 2 valid (non-empty) .txt files are required.", file=sys.stderr)
51
+ sys.exit(2)
52
+
53
+ results: List[EvaluationResult] = batch_evaluate(doc_dict, max_workers=args.jobs)
54
+
55
+ if args.json:
56
+ print(json.dumps([result_to_dict(r) for r in results], ensure_ascii=False, indent=2))
57
+ if args.json_file:
58
+ out_json_path = Path(args.json_file)
59
+ out_json_path.parent.mkdir(parents=True, exist_ok=True)
60
+ out_json_path.write_text(
61
+ json.dumps([result_to_dict(r) for r in results], ensure_ascii=False, indent=2), encoding="utf-8"
62
+ )
63
+ print(f"JSON report saved to: {out_json_path}")
64
+
65
+ output_dir = Path(args.output_dir)
66
+ output_dir.mkdir(parents=True, exist_ok=True)
67
+
68
+ diff_map = {}
69
+ for res in results:
70
+ doc_a_text = doc_dict.get(res.item_id, "")
71
+ doc_b_text = doc_dict.get(res.matched_peer_id, "")
72
+ pair_key = f"{res.item_id}_vs_{res.matched_peer_id}"
73
+ diff_map[pair_key] = generate_diff_html(doc_a_text, doc_b_text, res)
74
+
75
+ clusters = build_clusters(results)
76
+ indirect_chains = detect_indirect_chains(results, clusters)
77
+
78
+ if args.standalone:
79
+ out_file = output_dir / "sieve_dashboard_standalone.html"
80
+ generate_standalone_dashboard_html(results, diff_map, out_file)
81
+ print(f"Standalone HTML report generated: {out_file}")
82
+ else:
83
+ diff_dir = output_dir / "diff_htmls"
84
+ diff_dir.mkdir(exist_ok=True)
85
+ for pair_key, diff_html_str in diff_map.items():
86
+ (diff_dir / f"{pair_key}.html").write_text(diff_html_str, encoding="utf-8")
87
+ out_file = output_dir / "sieve_dashboard.html"
88
+ file_names = list(doc_dict.keys())
89
+ generate_dashboard_html(results, indirect_chains, file_names, out_file)
90
+ print(f"Standard HTML report generated: {out_file}")
91
+
92
+ has_red_risk = any(
93
+ (r.evaluation_signal.value if hasattr(r.evaluation_signal, "value") else str(r.evaluation_signal)) == "RED"
94
+ for r in results
95
+ )
96
+ if args.fail_on_risk and has_red_risk:
97
+ print("\n[CI AUDIT FAILED] High risk plagiarism (RED signal) detected!", file=sys.stderr)
98
+ sys.exit(1)
99
+ sys.exit(0)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -0,0 +1,61 @@
1
+ import html
2
+ import json
3
+ import math
4
+ from pathlib import Path
5
+ from typing import Dict, List, Tuple
6
+
7
+ from .models import EvaluationResult
8
+
9
+
10
+ def _calculate_graph_layout(file_names, results, indirect_chains, width=800, height=500):
11
+ n = len(file_names)
12
+ if n == 0:
13
+ return [], []
14
+ cx, cy = width / 2, height / 2
15
+ radius = min(width, height) * 0.35
16
+ nodes = []
17
+ node_pos = {}
18
+ for i, name in enumerate(file_names):
19
+ angle = (2 * math.pi * i) / n
20
+ x = cx + radius * math.cos(angle)
21
+ y = cy + radius * math.sin(angle)
22
+ nodes.append({"id": name, "label": name, "x": round(x, 1), "y": round(y, 1)})
23
+ node_pos[name] = (x, y)
24
+ edges = []
25
+ for res in results:
26
+ sig = getattr(res, "evaluation_signal", "GREEN")
27
+ sig_val = sig.value if hasattr(sig, "value") else str(sig)
28
+ if sig_val in ["RED", "YELLOW"]:
29
+ u, v = res.item_id, res.matched_peer_id
30
+ if u in node_pos and v in node_pos:
31
+ edges.append({"source": u, "target": v, "color": "#e03131" if sig_val == "RED" else "#f59f00"})
32
+ for chain in indirect_chains:
33
+ u, v = chain["item_id_a"], chain["item_id_b"]
34
+ if u in node_pos and v in node_pos:
35
+ edges.append({"source": u, "target": v, "color": "#fd7e14"})
36
+ return nodes, edges
37
+
38
+
39
+ def generate_dashboard_html(results, indirect_chains, file_names, output_path: Path) -> None:
40
+ nodes, edges = _calculate_graph_layout(file_names, results, indirect_chains)
41
+ alerts = ""
42
+ for c in indirect_chains:
43
+ alerts += f"<div class='alert-card'>ALERT: {c['item_id_a']} <-> {c['item_id_b']} (cluster: {', '.join(c['cluster_members'])})</div>\n"
44
+ html_content = f"""<html><body>
45
+ <h1>Dashboard</h1>
46
+ {alerts if alerts else '<p>NO_ALERTS</p>'}
47
+ </body></html>"""
48
+ output_path.write_text(html_content, encoding="utf-8")
49
+
50
+
51
+ def generate_standalone_dashboard_html(results, diff_map, output_path: Path) -> None:
52
+ embedded_json = json.dumps(diff_map, ensure_ascii=False)
53
+ html_content = f"""<!DOCTYPE html>
54
+ <html><head><meta charset="UTF-8"></head>
55
+ <body>
56
+ <h1>Sieve Referee Evaluation Dashboard (Standalone)</h1>
57
+ <script>
58
+ const EMBEDDED_DIFFS = {embedded_json};
59
+ </script>
60
+ </body></html>"""
61
+ output_path.write_text(html_content, encoding="utf-8")
@@ -0,0 +1,72 @@
1
+ import difflib
2
+ import html
3
+ from typing import Optional, Tuple
4
+
5
+ from .models import EvaluationResult
6
+ from .tokenizer import tokenize
7
+
8
+
9
+ def _build_highlighted_spans(text_a: str, text_b: str) -> Tuple[str, str, int, int]:
10
+ tokens_a = tokenize(text_a)
11
+ tokens_b = tokenize(text_b)
12
+
13
+ raw_a = [t.raw_text for t in tokens_a]
14
+ raw_b = [t.raw_text for t in tokens_b]
15
+
16
+ matcher = difflib.SequenceMatcher(None, raw_a, raw_b)
17
+ opcodes = matcher.get_opcodes()
18
+
19
+ html_a_parts = []
20
+ html_b_parts = []
21
+ match_chars = 0
22
+ replace_chars = 0
23
+
24
+ for tag, i1, i2, j1, j2 in opcodes:
25
+ chunk_a_text = "".join(raw_a[i1:i2])
26
+ chunk_b_text = "".join(raw_b[j1:j2])
27
+ sub_a = html.escape(chunk_a_text)
28
+ sub_b = html.escape(chunk_b_text)
29
+
30
+ if tag == "equal":
31
+ html_a_parts.append(f'[EQUAL:{sub_a}]')
32
+ html_b_parts.append(f'[EQUAL:{sub_b}]')
33
+ match_chars += len(chunk_a_text)
34
+ elif tag == "replace":
35
+ html_a_parts.append(f'[REPLACE:{sub_a}]')
36
+ html_b_parts.append(f'[REPLACE:{sub_b}]')
37
+ replace_chars += max(len(chunk_a_text), len(chunk_b_text))
38
+ elif tag == "delete":
39
+ html_a_parts.append(f'[UNIQUE:{sub_a}]')
40
+ elif tag == "insert":
41
+ html_b_parts.append(f'[UNIQUE:{sub_b}]')
42
+
43
+ return "".join(html_a_parts), "".join(html_b_parts), match_chars, replace_chars
44
+
45
+
46
+ def generate_side_by_side_html(doc_a: str, doc_b: str, evaluation_result=None) -> Tuple[str, str]:
47
+ span_a, span_b, _, _ = _build_highlighted_spans(doc_a, doc_b)
48
+ return span_a, span_b
49
+
50
+
51
+ def generate_diff_html(doc_a: str, doc_b: str, res) -> str:
52
+ pattern_name = res.pattern_name
53
+ sig_val = res.evaluation_signal.value if hasattr(res.evaluation_signal, "value") else str(res.evaluation_signal)
54
+ return f"""<!DOCTYPE html>
55
+ <html><head><meta charset="UTF-8"><title>Diff: {res.item_id} vs {res.matched_peer_id}</title></head>
56
+ <body><h2>{res.item_id} vs {res.matched_peer_id}</h2>
57
+ <p>Signal: {sig_val} | Mask: {res.mask} | Pattern: {pattern_name}</p>
58
+ <div>{doc_a}</div><hr><div>{doc_b}</div></body></html>"""
59
+
60
+
61
+ def export_diff_reports(results, documents, output_dir):
62
+ diff_dir = output_dir / "diff_htmls"
63
+ diff_dir.mkdir(parents=True, exist_ok=True)
64
+ for res in results:
65
+ sig = getattr(res, "evaluation_signal", "GREEN")
66
+ sig_val = sig.value if hasattr(sig, "value") else str(sig)
67
+ if sig_val in ["RED", "YELLOW"]:
68
+ doc_a = documents.get(res.item_id, "")
69
+ doc_b = documents.get(res.matched_peer_id, "")
70
+ html_content = generate_diff_html(doc_a, doc_b, res)
71
+ file_name = f"diff_{res.item_id}_vs_{res.matched_peer_id}.html"
72
+ (diff_dir / file_name).write_text(html_content, encoding="utf-8")
@@ -0,0 +1,118 @@
1
+ import os
2
+ import unicodedata
3
+ from concurrent.futures import ProcessPoolExecutor
4
+ from itertools import combinations
5
+ from typing import Dict, List, Set, Tuple
6
+
7
+ from .blocker import DeterministicBlocker
8
+ from .models import EvaluationResult
9
+ from .referee import LUT_RULES, evaluate_pair
10
+
11
+
12
+ def build_clusters(results: List[EvaluationResult]) -> Dict[str, List[str]]:
13
+ """流用シグナル(RED/YELLOW)が存在するペアから連結成分(クラスタ)を構築する。"""
14
+ adj: Dict[str, Set[str]] = {}
15
+ all_nodes: Set[str] = set()
16
+ for res in results:
17
+ u, v = res.item_id, res.matched_peer_id
18
+ all_nodes.add(u)
19
+ all_nodes.add(v)
20
+ sig = getattr(res, "evaluation_signal", "GREEN")
21
+ sig_val = sig.value if hasattr(sig, "value") else str(sig)
22
+ if sig_val in ["RED", "YELLOW"]:
23
+ adj.setdefault(u, set()).add(v)
24
+ adj.setdefault(v, set()).add(u)
25
+
26
+ visited: Set[str] = set()
27
+ clusters_by_item: Dict[str, List[str]] = {}
28
+ for node in all_nodes:
29
+ if node not in visited:
30
+ component = []
31
+ queue = [node]
32
+ visited.add(node)
33
+ while queue:
34
+ curr = queue.pop(0)
35
+ component.append(curr)
36
+ for neighbor in adj.get(curr, []):
37
+ if neighbor not in visited:
38
+ visited.add(neighbor)
39
+ queue.append(neighbor)
40
+ component.sort()
41
+ for member in component:
42
+ clusters_by_item[member] = component
43
+ return clusters_by_item
44
+
45
+
46
+ def update_h4_in_results(results: List[EvaluationResult], clusters: Dict[str, List[str]]) -> List[EvaluationResult]:
47
+ """
48
+ 安全不変式: b0(H4)=1 への昇格は、そのペア自身に直接の類似根拠(b2=H2 または b3=H3)が
49
+ 存在し、かつサイズ3以上のクラスタに属する場合のみに限定する。
50
+ 直接の根拠がない伝言ゲーム型の間接連鎖ペアは、ここではマスクを変更しない
51
+ (別途 chain_detector.detect_indirect_chains で検出・報告する)。
52
+ """
53
+ for res in results:
54
+ u, v = res.item_id, res.matched_peer_id
55
+ cluster_u = clusters.get(u, [])
56
+ if len(cluster_u) >= 3:
57
+ mask_chars = list(res.mask)
58
+ if len(mask_chars) == 4:
59
+ b1, b2, b3, _ = mask_chars
60
+ has_direct_edge = b2 == "1" or b3 == "1"
61
+ if has_direct_edge:
62
+ new_mask = f"{b1}{b2}{b3}1"
63
+ res.mask = new_mask
64
+ if new_mask in LUT_RULES:
65
+ signal, pattern, reason = LUT_RULES[new_mask]
66
+ res.pattern_name = pattern
67
+ res.reason = reason
68
+ res.evaluation_signal = signal
69
+ return results
70
+
71
+
72
+ def _worker_evaluate_pair(args: Tuple[str, str, str, str]) -> EvaluationResult:
73
+ id_a, text_a, id_b, text_b = args
74
+ return evaluate_pair(text_a, text_b, id_a=id_a, id_b=id_b)
75
+
76
+
77
+ def batch_evaluate(documents: Dict[str, str], max_workers: int = None) -> List[EvaluationResult]:
78
+ """
79
+ 文書辞書(filename -> content)を受け取り、
80
+ NFKC正規化 -> 決定論的プレ・ブロッキング -> (並列/逐次)ペア評価 ->
81
+ クラスタ構築 -> H4安全昇格、という一連のパイプラインを実行する。
82
+ """
83
+ normalized_docs = {doc_id: unicodedata.normalize("NFKC", content) for doc_id, content in documents.items()}
84
+ doc_ids = sorted(normalized_docs.keys())
85
+
86
+ blocker = DeterministicBlocker(ngram_size=3, min_shared_ngrams=1)
87
+ candidate_pairs = blocker.get_candidate_pairs(normalized_docs)
88
+
89
+ raw_results: List[EvaluationResult] = []
90
+ non_candidate_pairs: List[Tuple[str, str]] = []
91
+ candidate_args: List[Tuple[str, str, str, str]] = []
92
+
93
+ for id_a, id_b in combinations(doc_ids, 2):
94
+ if (id_a, id_b) in candidate_pairs:
95
+ candidate_args.append((id_a, normalized_docs[id_a], id_b, normalized_docs[id_b]))
96
+ else:
97
+ non_candidate_pairs.append((id_a, id_b))
98
+
99
+ if candidate_args:
100
+ workers = max_workers or min(os.cpu_count() or 4, len(candidate_args))
101
+ if workers > 1 and len(candidate_args) >= 4:
102
+ with ProcessPoolExecutor(max_workers=workers) as executor:
103
+ raw_results.extend(executor.map(_worker_evaluate_pair, candidate_args))
104
+ else:
105
+ for args in candidate_args:
106
+ raw_results.append(_worker_evaluate_pair(args))
107
+
108
+ signal, pattern, reason = LUT_RULES["1000"]
109
+ for id_a, id_b in non_candidate_pairs:
110
+ raw_results.append(EvaluationResult(
111
+ item_id=id_a, matched_peer_id=id_b, evaluation_signal=signal,
112
+ pattern_name=pattern, reason=reason, mask="1000",
113
+ structural_density=0.0, max_structure_similarity=0.0,
114
+ max_content_similarity=0.0, coverage=0.0,
115
+ ))
116
+
117
+ clusters = build_clusters(raw_results)
118
+ return update_h4_in_results(raw_results, clusters)
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+ import unicodedata
3
+
4
+ SUPPORTED_ENCODINGS = ("utf-8", "utf-8-sig", "cp932", "shift_jis", "euc_jp")
5
+
6
+
7
+ def safe_read_text(file_path):
8
+ path = Path(file_path)
9
+ if not path.exists() or not path.is_file():
10
+ return None
11
+ if path.stat().st_size == 0:
12
+ return None
13
+ raw_bytes = path.read_bytes()
14
+ decoded_text = None
15
+ for enc in SUPPORTED_ENCODINGS:
16
+ try:
17
+ decoded_text = raw_bytes.decode(enc)
18
+ break
19
+ except (UnicodeDecodeError, ValueError):
20
+ continue
21
+ if decoded_text is None:
22
+ return None
23
+ normalized = unicodedata.normalize("NFKC", decoded_text).strip()
24
+ if not normalized:
25
+ return None
26
+ return normalized
@@ -0,0 +1,33 @@
1
+ from typing import List, Set
2
+ from .models import Token
3
+ from .tokenizer import extract_keywords_with_subgrams, tokenize
4
+
5
+
6
+ def calc_h1_density(text: str) -> float:
7
+ if not text:
8
+ return 0.0
9
+ return len(set(text)) / len(text)
10
+
11
+
12
+ def _generate_ngrams(tokens: List[str], n: int) -> Set[str]:
13
+ if len(tokens) < n:
14
+ return {"_".join(tokens)} if tokens else set()
15
+ return {"_".join(tokens[i : i + n]) for i in range(len(tokens) - n + 1)}
16
+
17
+
18
+ def calc_h2_structure_similarity(tokens_a: List[Token], tokens_b: List[Token]) -> float:
19
+ skel_a = [t.skeleton_tag for t in tokens_a]
20
+ skel_b = [t.skeleton_tag for t in tokens_b]
21
+ ngrams_a = _generate_ngrams(skel_a, n=5) | _generate_ngrams(skel_a, n=6)
22
+ ngrams_b = _generate_ngrams(skel_b, n=5) | _generate_ngrams(skel_b, n=6)
23
+ if not ngrams_a or not ngrams_b:
24
+ return 0.0
25
+ return len(ngrams_a & ngrams_b) / len(ngrams_a | ngrams_b)
26
+
27
+
28
+ def calc_h3_content_similarity(text_a: str, text_b: str) -> float:
29
+ feats_a = extract_keywords_with_subgrams(text_a)
30
+ feats_b = extract_keywords_with_subgrams(text_b)
31
+ if not feats_a or not feats_b:
32
+ return 0.0
33
+ return len(feats_a & feats_b) / len(feats_a | feats_b)
@@ -0,0 +1,36 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Optional
4
+
5
+
6
+ class SignalColor(str, Enum):
7
+ GREEN = "GREEN"
8
+ YELLOW = "YELLOW"
9
+ RED = "RED"
10
+
11
+
12
+ @dataclass
13
+ class Token:
14
+ raw_text: str
15
+ skeleton_tag: str
16
+
17
+
18
+ @dataclass
19
+ class Chunk:
20
+ chunk_id: int
21
+ text: str
22
+ window_size: int
23
+
24
+
25
+ @dataclass
26
+ class EvaluationResult:
27
+ item_id: str
28
+ evaluation_signal: SignalColor
29
+ mask: str
30
+ pattern_name: str
31
+ reason: str
32
+ structural_density: float
33
+ max_structure_similarity: float
34
+ max_content_similarity: float
35
+ coverage: float
36
+ matched_peer_id: Optional[str] = None
@@ -0,0 +1,80 @@
1
+ from typing import Dict, Optional
2
+ from .chunker import split_into_multi_scale_windows
3
+ from .metrics import calc_h1_density, calc_h2_structure_similarity, calc_h3_content_similarity
4
+ from .models import EvaluationResult, SignalColor
5
+ from .tokenizer import tokenize
6
+
7
+ LUT_RULES: Dict[str, tuple] = {
8
+ "1110": (SignalColor.RED, "EXACT_COPY_AND_FORMAT_MATCH", "共通フォーマットに沿った文章構造および本文内容の完全なコピーが検出されました。"),
9
+ "1100": (SignalColor.YELLOW, "COINCIDENTAL_FORMAT_MATCH", "指定課題のフォーマットや文章構造の型が一致しています(本文内容は独自)。"),
10
+ "1010": (SignalColor.RED, "PARAPHRASE_DETECTED", "構文構造は独自ですが、内容語の高度な一致(パラフレーズ・語彙置換による流用)が検出されました。"),
11
+ "1000": (SignalColor.GREEN, "STANDARD_STRUCTURE_UNIQUE", "標準的な記述密度を持った独自の文章です。"),
12
+ "0110": (SignalColor.RED, "FORMATTED_PARAPHRASE", "特殊構造内での内容流用・言い換えが検出されました。"),
13
+ "0100": (SignalColor.YELLOW, "FORMAT_ONLY_MATCH", "定型構造のみの一致です(内容は独自)。"),
14
+ "0010": (SignalColor.RED, "UNFORMATTED_CONTENT_COPY", "構造化されていないテキスト間での本文直接コピー・流用が検出されました。"),
15
+ "0000": (SignalColor.GREEN, "FULLY_UNIQUE", "構造・内容ともに完全な独自記述です。"),
16
+ "1111": (SignalColor.RED, "CLUSTER_EXACT_COPY", "複数人による集団的な完全コピペネットワークが検出されました。"),
17
+ "1101": (SignalColor.YELLOW, "CLUSTER_FORMAT_SHARING", "複数人で共通テンプレートを使い回しているクラスタです。"),
18
+ "1011": (SignalColor.RED, "CLUSTER_PARAPHRASE_GROUP", "複数人による集団的な言い換え・単語置換グループが検出されました。"),
19
+ "1001": (SignalColor.GREEN, "CLUSTER_DENSE_UNIQUE", "特定グループ内での標準的な記述です。"),
20
+ }
21
+
22
+
23
+ class SieveReferee:
24
+ def __init__(self, h1_threshold: float = 0.35, h2_threshold: float = 0.80, h3_threshold: float = 0.45):
25
+ self.h1_th = h1_threshold
26
+ self.h2_th = h2_threshold
27
+ self.h3_th = h3_threshold
28
+
29
+ def evaluate_pair(self, item_id: str, doc_a: str, doc_b: str, peer_id: Optional[str] = None) -> EvaluationResult:
30
+ h1_score_a = calc_h1_density(doc_a)
31
+ b1 = 1 if h1_score_a >= self.h1_th else 0
32
+
33
+ tokens_a = tokenize(doc_a)
34
+ tokens_b = tokenize(doc_b)
35
+ h2_score = calc_h2_structure_similarity(tokens_a, tokens_b)
36
+ b2 = 1 if h2_score >= self.h2_th else 0
37
+
38
+ sentences_a = [s for s in doc_a.split("。") if s.strip()]
39
+ chunks_a = split_into_multi_scale_windows(doc_a, [1, 2])
40
+ chunks_b = split_into_multi_scale_windows(doc_b, [1, 2])
41
+
42
+ max_h3 = 0.0
43
+ flagged_sentences = set()
44
+ for ca in chunks_a:
45
+ for cb in chunks_b:
46
+ h3 = calc_h3_content_similarity(ca.text, cb.text)
47
+ if h3 > max_h3:
48
+ max_h3 = h3
49
+ if h3 >= self.h3_th and ca.window_size == 1:
50
+ flagged_sentences.add(ca.chunk_id)
51
+
52
+ b3 = 1 if max_h3 >= self.h3_th else 0
53
+ coverage = len(flagged_sentences) / len(sentences_a) if sentences_a else 0.0
54
+
55
+ b4 = 0
56
+ mask = f"{b1}{b2}{b3}{b4}"
57
+ signal, pattern, reason = LUT_RULES.get(mask, (SignalColor.YELLOW, "UNKNOWN_PATTERN", "未定義パターンの判定です。確認が必要です。"))
58
+
59
+ return EvaluationResult(
60
+ item_id=item_id,
61
+ evaluation_signal=signal,
62
+ mask=mask,
63
+ pattern_name=pattern,
64
+ reason=reason,
65
+ structural_density=round(h1_score_a, 4),
66
+ max_structure_similarity=round(h2_score, 4),
67
+ max_content_similarity=round(max_h3, 4),
68
+ coverage=round(coverage, 4),
69
+ matched_peer_id=peer_id,
70
+ )
71
+
72
+
73
+ _default_referee = None
74
+
75
+
76
+ def evaluate_pair(doc_a: str, doc_b: str, id_a: str = "doc_a", id_b: str = "doc_b"):
77
+ global _default_referee
78
+ if _default_referee is None:
79
+ _default_referee = SieveReferee()
80
+ return _default_referee.evaluate_pair(item_id=id_a, doc_a=doc_a, doc_b=doc_b, peer_id=id_b)
@@ -0,0 +1,47 @@
1
+ import re
2
+ from typing import List, Set
3
+ from .models import Token
4
+
5
+ PATTERN = re.compile(
6
+ r"(https?://\S+|www\.\S+)|"
7
+ r"(\d+)|"
8
+ r"([\u4e00-\u9fff]+)|"
9
+ r"([\u30a0-\u30ff]+)|"
10
+ r"([\u3040-\u309f]+)|"
11
+ r"([a-zA-Z_]+)|"
12
+ r"([^\w\s])"
13
+ )
14
+
15
+
16
+ def tokenize(text: str) -> List[Token]:
17
+ tokens = []
18
+ for match in PATTERN.finditer(text):
19
+ url, num, kanji, katakana, hiragana, alpha, symbol = match.groups()
20
+ raw = match.group(0)
21
+ if url:
22
+ tokens.append(Token(raw, "<URL>"))
23
+ elif num:
24
+ tokens.append(Token(raw, "<NUM>"))
25
+ elif kanji or katakana or alpha:
26
+ tokens.append(Token(raw, "<KW>"))
27
+ elif hiragana or symbol:
28
+ tokens.append(Token(raw, raw))
29
+ return tokens
30
+
31
+
32
+ def extract_keywords_with_subgrams(text: str, min_gram: int = 2) -> Set[str]:
33
+ tokens = tokenize(text)
34
+ features = set()
35
+ for t in tokens:
36
+ if t.skeleton_tag == "<KW>":
37
+ raw = t.raw_text
38
+ features.add(raw)
39
+ if len(raw) >= min_gram:
40
+ for i in range(len(raw) - min_gram + 1):
41
+ features.add(raw[i : i + min_gram])
42
+ return features
43
+
44
+
45
+ def extract_keywords(text: str) -> Set[str]:
46
+ tokens = tokenize(text)
47
+ return {t.raw_text for t in tokens if t.skeleton_tag == "<KW>"}
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: sieve-referee
3
+ Version: 1.0.0
4
+ Summary: Deterministic, zero-dependency plagiarism and text structure screening engine (Sieve-Core plugin)
5
+ Author: Kai IWASAKI
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/neguseatama/sieve-referee
8
+ Project-URL: Repository, https://github.com/neguseatama/sieve-referee.git
9
+ Project-URL: Related Project (Sieve-Core), https://github.com/neguseatama/sieve-core
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # Sieve Referee
16
+
17
+ English | [日本語](README.ja.md)
18
+
19
+ > **"Should we really use probabilistic AI models to evaluate text generated by other probabilistic AI models?"**
20
+ > A fully local, zero-dependency, and deterministic (100% reproducible) text structure and plagiarism screening engine. Developed as an extended application of [Sieve-Core](https://github.com/neguseatama/sieve-core).
21
+
22
+ ---
23
+
24
+ ## 💡 Concept & Philosophy
25
+
26
+ `Sieve-Core` is the core engine that applies the design philosophy of "Sieve-AI"—a "sieve of set theory" inspired by the Sieve of Eratosthenes—to the more general domain of text and data processing. `Sieve Referee`, designed by adding a "sieve of combinatorics" to this `Sieve-Core`, is a zero-dependency Python library that extracts reliable information from noise based on corroboration from multiple independent sources.
27
+
28
+ When evaluating text that may have been generated by probabilistic models (such as LLMs), relying on another probabilistic model to calculate a similarity score makes it difficult to mathematically trace *why* that score was produced.
29
+
30
+ `Sieve Referee` was built to address this problem: to ensure every judgment is mathematically traceable, 100% reproducible, and fully explainable.
31
+
32
+ - **Deterministic Approach**: Evaluation is based on a 16-state Look-Up Table (LUT, of which 12 states are reachable). Identical inputs always produce identical outputs no matter how many times it is run.
33
+ - **Explainable by Design**: Every result carries a `reason` string and a 4-bit hypothesis `mask`, so the basis of a judgment can always be inspected.
34
+ - **Careful Classification to Avoid False Accusations**: A shared template or boilerplate format is classified separately as `COINCIDENTAL_FORMAT_MATCH` (`1100` / `YELLOW`), rather than being treated the same as an actual content match.
35
+ - **Indirect ("Telephone Game") Chain Safety**: A pair with no direct similarity evidence (e.g. `A <-> C`, where `A <-> B` and `B <-> C` are both flagged) is never silently upgraded to a positive match solely based on cluster membership. Such pairs are reported separately on the dashboard as an "indirect chain (telephone game) warning," and the direct comparison result itself is left unmodified.
36
+
37
+ ---
38
+
39
+ ## 🎯 Primary Use Cases
40
+
41
+ Built with zero external dependencies and in-memory execution, `sieve_referee` is well suited as an embedded, privacy-focused screening engine.
42
+
43
+ ### 1. Education & Learning Management Systems (EdTech / LMS)
44
+ Screen student submissions without uploading personal data or copyrighted text to a third-party cloud service. Common assignment templates are classified as `YELLOW`; unattributed copying or paraphrasing is flagged as `RED`.
45
+
46
+ ### 2. Digital Publishing & CMS
47
+ Detect rewritten or recycled content submitted by freelance writers at draft-save time, without incurring LLM API costs for every check.
48
+
49
+ ### 3. LLM Training Data Deduplication
50
+ Use as a deterministic pre-processing stage to remove near-duplicate or paraphrased records from a fine-tuning dataset, without GPU acceleration.
51
+
52
+ ### 4. Legal, Contract & RFP Review
53
+ Separate boilerplate clauses (`COINCIDENTAL_FORMAT_MATCH`) from intentionally altered wording across contract revisions or vendor proposals.
54
+
55
+ ### 5. CI/CD Documentation Auditing
56
+ Run in a GitHub Actions workflow or pre-commit hook to catch unauthorized copy-pasting across internal wikis or specification documents.
57
+
58
+ ---
59
+
60
+ ## ⚡ Key Technical Advantages
61
+
62
+ 1. **Near-zero cold start** — no PyTorch, no Transformers, no model loading; well suited to AWS Lambda or other serverless environments.
63
+ 2. **Zero per-call cost** — everything runs on local CPU.
64
+ 3. **Zero maintenance burden** — zero third-party dependencies means no exposure to upstream CVEs or breaking API changes.
65
+
66
+ ---
67
+
68
+ ## 📦 Installation
69
+
70
+ ```bash
71
+ pip install git+https://github.com/neguseatama/sieve-referee.git
72
+ ```
73
+
74
+ or, from a local clone:
75
+
76
+ ```bash
77
+ git clone https://github.com/neguseatama/sieve-referee.git
78
+ cd sieve-referee
79
+ pip install .
80
+ ```
81
+
82
+ ---
83
+
84
+ ## 🐍 Usage (Python Library)
85
+
86
+ ```python
87
+ from sieve_referee import batch_evaluate, EvaluationResult
88
+
89
+ # 1. Prepare input documents in memory
90
+ documents = {
91
+ "doc_a.txt": "Quantum computing relies on superposition and entanglement to achieve fast factorization.",
92
+ "doc_b.txt": "To achieve fast factorization, quantum computing makes use of entanglement and superposition."
93
+ }
94
+
95
+ # 2. Run the screening
96
+ results: list[EvaluationResult] = batch_evaluate(documents)
97
+
98
+ # 3. Read the results
99
+ for res in results:
100
+ print(f"Pair: {res.item_id} <-> {res.matched_peer_id}")
101
+ print(f"Signal: {res.evaluation_signal.value}") # -> RED
102
+ print(f"Mask: {res.mask}") # -> 0010
103
+ print(f"Pattern: {res.pattern_name}") # -> UNFORMATTED_CONTENT_COPY
104
+ print(f"Reason: {res.reason}")
105
+ print("-" * 40)
106
+ ```
107
+
108
+ ### `EvaluationResult` fields
109
+
110
+ | Property | Type | Description |
111
+ |---|---|---|
112
+ | `item_id` | `str` | Primary document identifier |
113
+ | `matched_peer_id` | `str` | Comparison peer document identifier |
114
+ | `evaluation_signal` | `SignalColor` | Result signal (`.value` returns `"GREEN"`, `"YELLOW"`, or `"RED"`) |
115
+ | `mask` | `str` | 4-bit hypothesis mask, e.g. `"1010"` |
116
+ | `pattern_name` | `str` | LUT pattern name, e.g. `"PARAPHRASE_DETECTED"` |
117
+ | `reason` | `str` | Human-readable explanation of the judgment |
118
+
119
+ ---
120
+
121
+ ## 💻 Usage (CLI Tool)
122
+
123
+ ```bash
124
+ # Basic directory scan (writes reports/sieve_dashboard.html)
125
+ sieve-referee ./target_documents
126
+
127
+ # Single self-contained HTML report (no diff_htmls/ subfolder)
128
+ sieve-referee ./target_documents --standalone --output-dir ./reports
129
+
130
+ # JSON output for automated pipelines, with a non-zero exit code on RED
131
+ sieve-referee ./target_documents --json --fail-on-risk
132
+
133
+ # Multi-core evaluation
134
+ sieve-referee ./target_documents -j 4
135
+ ```
136
+
137
+ | Option | Type | Description |
138
+ |---|---|---|
139
+ | `target_dir` | positional | Directory containing the `.txt` files to screen |
140
+ | `--output-dir` | string | Report output directory (default: `reports`) |
141
+ | `--standalone` | flag | Emit a single self-contained HTML report instead of a report + `diff_htmls/` folder |
142
+ | `--json` | flag | Print results as JSON to stdout |
143
+ | `--json-file` | string | Save results as JSON to the given path |
144
+ | `--fail-on-risk` | flag | Exit with status code `1` if any `RED` signal is found |
145
+ | `-j`, `--jobs` | int | Number of worker processes for evaluation (default: CPU count) |
146
+
147
+ ---
148
+
149
+ ## 📊 16-State Look-Up Table (LUT)
150
+
151
+ Four independent hypotheses (`H1`–`H4`) are combined into a 4-bit mask, which maps deterministically to a signal, pattern name, and reason.
152
+
153
+ | Mask | Signal | Pattern Name | Description |
154
+ |:---:|:---:|:---|:---|
155
+ | `1000` | 🟢 GREEN | `STANDARD_STRUCTURE_UNIQUE` | Independent document, no structural or content overlap detected. |
156
+ | `1100` | 🟡 YELLOW | `COINCIDENTAL_FORMAT_MATCH` | Shared format/template only; content itself is independent. |
157
+ | `1010` | 🔴 RED | `PARAPHRASE_DETECTED` | Content overlap despite a different structure (paraphrasing). |
158
+ | `1011` | 🔴 RED | `CLUSTER_PARAPHRASE_GROUP` | Paraphrased reuse confirmed within a 3+ document cluster. |
159
+ | `1111` | 🔴 RED | `CLUSTER_EXACT_COPY` | Exact/templated copy confirmed within a 3+ document cluster. |
160
+
161
+ (12 of the 16 possible mask states are reachable in practice; the remaining 4 states, where cluster membership is set without any direct similarity evidence, are structurally unreachable by design.)
162
+
163
+ > **Note on `H4` (cluster membership) and indirect chains**
164
+ > `H4` is only set when a pair has *both* direct similarity evidence (`H2` or `H3`) *and* cluster membership of size ≥ 3. A pair with no direct evidence is never upgraded through cluster membership alone — such "telephone game" chains are reported separately as an indirect-chain warning on the dashboard, and the direct pair's own result is left unmodified.
165
+
166
+ ---
167
+
168
+ ## ⚠️ Known Limitation: Threshold Calibration Is Japanese-Specific
169
+
170
+ All four thresholds (`H1 = 0.35`, `H2 = 0.80`, `H3 = 0.45`, cluster size `≥ 3`)
171
+ were calibrated using real-data testing on **Japanese** text (roughly 1,300+
172
+ pairs across unrelated documents, paraphrase pairs, and formal academic
173
+ prose).
174
+
175
+ The `H1` density metric in particular (`len(set(text)) / len(text)`) behaves
176
+ very differently across scripts: Japanese text draws from a large set of
177
+ kanji, so unique-character ratios tend to be high, while English and other
178
+ alphabetic-script text reuses a small ~26-letter alphabet and will
179
+ systematically score lower on this metric, independent of how generic or
180
+ templated the content actually is. As a result, non-Japanese text may be
181
+ under- or over-classified against the current defaults.
182
+
183
+ Multi-encoding file reading (UTF-8, Shift_JIS, CP932, EUC-JP) and NFKC
184
+ normalization work for any text regardless of language. What has **not**
185
+ been separately validated is the semantic quality of `H2`/`H3` splitting,
186
+ or the appropriateness of the default thresholds, for languages other than
187
+ Japanese. If you use this on non-Japanese corpora, re-run the false-positive
188
+ check described in [ARCHITECTURE.md](docs/ARCHITECTURE.md) against your own
189
+ data before trusting the default thresholds.
190
+
191
+ Additionally, the `reason` string returned by every `EvaluationResult` is
192
+ currently hard-coded in Japanese regardless of the input document's
193
+ language, as shown in the example output above. There is no English (or
194
+ other-language) localization of these messages yet.
195
+
196
+ ---
197
+
198
+ ## 📚 Documentation
199
+
200
+ - 🔌 [API & Integration Guide](docs/INTEGRATION.md) — using `sieve_referee` as a Python library or FastAPI microservice
201
+ - 🚀 [Deployment Guide](docs/DEPLOYMENT.md) — Docker and Terraform for Google Cloud Run
202
+ - 🔐 [CI/CD & Security Guide](docs/CI_CD_SETUP.md) — keyless GCP deployment via Workload Identity Federation
203
+ - 🏛️ [Architecture Specification](docs/ARCHITECTURE.md) — the mathematical model and safety invariants in detail
204
+
205
+ ## 📄 License
206
+
207
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ sieve_referee/__init__.py,sha256=RWesn2RshNlis2l3OyKB4722fjyZVNpPdhyqJ1J_mPE,355
2
+ sieve_referee/blocker.py,sha256=gvbFVvJGy5pdMRujptugQ-UmepRGU53y8w_KAGxX8B0,1751
3
+ sieve_referee/chain_detector.py,sha256=SrnoAiGYQ6AORDF6IygQRU-ohIPkHRXo5ft0Em4XSXg,1221
4
+ sieve_referee/chunker.py,sha256=cr5-MTAHNJpK_NegScT4Y9lf8rApptRISZo87yUNya8,557
5
+ sieve_referee/cli.py,sha256=CDwMaRuivLAc06r_wM02mjU8dgFRNwI82Y90KYuj3ok,4057
6
+ sieve_referee/dashboard_builder.py,sha256=77UWBI-srNiMBK5Sh2e527FV5HdBxW0YDfCSxx4wfno,2294
7
+ sieve_referee/diff_builder.py,sha256=IFI_ebB8gU0eX9xgFeuiIfpAIJjCgjwqUp4oqd1diis,2793
8
+ sieve_referee/evaluator.py,sha256=BwDD9L5XO3WBcFKlOUKKHk821gmPxfrxOWvn1TORD1c,5034
9
+ sieve_referee/io_utils.py,sha256=HkJIcLpQwWgVnliGhNOZOeS7pp7yEGE5EcpGEewGueI,730
10
+ sieve_referee/metrics.py,sha256=bMDzdNYT7BmK5PKAaaVJNdpu3oeKi88hGA9QGYN2v5A,1203
11
+ sieve_referee/models.py,sha256=qHIGcCAG_vzQIf0NzjGtaQlqEIC9c0BSuDAkk5b6URU,622
12
+ sieve_referee/referee.py,sha256=MfHT9IsyT15Uvc39tTn6G33S8dSbTmnXcyQocgV9GpM,4345
13
+ sieve_referee/tokenizer.py,sha256=gDA-HwxMRlWoDehi7-YsScfzZhN0Mq2B4FUrN787Rv0,1327
14
+ sieve_referee-1.0.0.dist-info/licenses/LICENSE,sha256=elLpPoGcCA_Tr2ln2abEvan7pEj_LoC4Q0-z5cudib8,1068
15
+ sieve_referee-1.0.0.dist-info/METADATA,sha256=J-CfaBo8tjpTFnAZFhsvryVWmz6-HBLuiQN-1zevo5o,10513
16
+ sieve_referee-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
+ sieve_referee-1.0.0.dist-info/entry_points.txt,sha256=Yb-iXdSaMta485OGpzlBT_vOrpAx1sdXDrqcvj4en24,57
18
+ sieve_referee-1.0.0.dist-info/top_level.txt,sha256=9p33A4hueB8pP2wmczfziL-mx1vvV8T1QrHZecuKXL4,14
19
+ sieve_referee-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sieve-referee = sieve_referee.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kai IWASAKI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sieve_referee