fix: bound memory read continuations
This commit is contained in:
@@ -4592,6 +4592,11 @@ pub struct SubjektivMemoryReadRequest {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
pub offset: Option<usize>,
|
pub offset: Option<usize>,
|
||||||
|
/// UTF-8 byte offset within `offset`'s line. Used only for continuation of
|
||||||
|
/// one line that exceeds the response byte budget.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
|
pub byte_offset: Option<usize>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[schemars(range(min = 1, max = 1000))]
|
#[schemars(range(min = 1, max = 1000))]
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
@@ -4719,9 +4724,14 @@ pub struct SubjektivMemoryReadResponse {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
pub body_offset: usize,
|
pub body_offset: usize,
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
|
pub body_byte_offset: usize,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
pub body_next_offset: Option<usize>,
|
pub body_next_offset: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
|
pub body_next_byte_offset: Option<usize>,
|
||||||
pub body_truncated: bool,
|
pub body_truncated: bool,
|
||||||
/// Candidate ids on this evidence page. The corresponding bounded anchors
|
/// Candidate ids on this evidence page. The corresponding bounded anchors
|
||||||
/// are returned in `source_candidates` with the same order.
|
/// are returned in `source_candidates` with the same order.
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision";
|
|||||||
const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit";
|
const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit";
|
||||||
const RETRY_HOOK: &str = "retry-committed-explicit-memory";
|
const RETRY_HOOK: &str = "retry-committed-explicit-memory";
|
||||||
const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite";
|
const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite";
|
||||||
const MAX_EVIDENCE_REFS: usize = 20;
|
const MAX_EVIDENCE_REFS: usize = 10;
|
||||||
|
|
||||||
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
|
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
|
||||||
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line-bounded Markdown and paged provenance anchors. Continue partial reads using the returned exact revision.";
|
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact revision, line offset, and byte offset.";
|
||||||
const LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination.";
|
const LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination.";
|
||||||
const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit.";
|
const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit.";
|
||||||
const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory revision. This validates optimistic concurrency now but never creates a confirmed revision.";
|
const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory revision. This validates optimistic concurrency now but never creates a confirmed revision.";
|
||||||
|
|||||||
@@ -16775,10 +16775,11 @@ const SUBJEKTIV_QUERY_DEFAULT_LIMIT: usize = 20;
|
|||||||
const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100;
|
const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100;
|
||||||
const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200;
|
const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200;
|
||||||
const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000;
|
const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000;
|
||||||
const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 20;
|
const SUBJEKTIV_BODY_MAX_BYTES: usize = 16 * 1024;
|
||||||
const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = 20;
|
const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 1;
|
||||||
|
const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = crate::subjektiv::MAX_STAGING_ANCHORS;
|
||||||
const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64;
|
const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64;
|
||||||
const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 32 * 1024;
|
const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -17003,7 +17004,8 @@ fn subjektiv_memory_read(
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
let body_offset = input.offset.unwrap_or(0);
|
let body_offset = input.offset.unwrap_or(0);
|
||||||
if body_offset > 0 && input.revision.is_none() {
|
let body_byte_offset = input.byte_offset.unwrap_or(0);
|
||||||
|
if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() {
|
||||||
return Err(Error::InvalidInput(
|
return Err(Error::InvalidInput(
|
||||||
"body continuation requires the exact revision returned by the first read".into(),
|
"body continuation requires the exact revision returned by the first read".into(),
|
||||||
)
|
)
|
||||||
@@ -17013,9 +17015,28 @@ fn subjektiv_memory_read(
|
|||||||
if body_offset > lines.len() {
|
if body_offset > lines.len() {
|
||||||
return Err(Error::InvalidInput("body line offset exceeds document length".into()).into());
|
return Err(Error::InvalidInput("body line offset exceeds document length".into()).into());
|
||||||
}
|
}
|
||||||
let body_end = body_offset.saturating_add(body_limit).min(lines.len());
|
if body_offset == lines.len() && body_byte_offset > 0 {
|
||||||
let body_truncated = body_end < lines.len();
|
return Err(Error::InvalidInput("body byte offset exceeds document length".into()).into());
|
||||||
let body_md = lines[body_offset..body_end].concat();
|
}
|
||||||
|
if let Some(line) = lines.get(body_offset)
|
||||||
|
&& (body_byte_offset > line.len() || !line.is_char_boundary(body_byte_offset))
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"body byte offset must be a UTF-8 boundary within the selected line".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let (body_md, body_next) = bounded_memory_body_page(
|
||||||
|
&lines,
|
||||||
|
body_offset,
|
||||||
|
body_byte_offset,
|
||||||
|
body_limit,
|
||||||
|
SUBJEKTIV_BODY_MAX_BYTES,
|
||||||
|
);
|
||||||
|
let body_truncated = body_next.is_some();
|
||||||
|
let (body_next_offset, body_next_byte_offset) = body_next
|
||||||
|
.map(|(line, byte)| (Some(line), Some(byte)))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
let evidence_offset = if let Some(cursor) = input.evidence_cursor {
|
let evidence_offset = if let Some(cursor) = input.evidence_cursor {
|
||||||
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
|
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
|
||||||
@@ -17124,7 +17145,9 @@ fn subjektiv_memory_read(
|
|||||||
created_at: record.created_at,
|
created_at: record.created_at,
|
||||||
updated_at: record.updated_at,
|
updated_at: record.updated_at,
|
||||||
body_offset,
|
body_offset,
|
||||||
body_next_offset: body_truncated.then_some(body_end),
|
body_byte_offset,
|
||||||
|
body_next_offset,
|
||||||
|
body_next_byte_offset,
|
||||||
body_truncated,
|
body_truncated,
|
||||||
source_candidate_ids,
|
source_candidate_ids,
|
||||||
source_candidates,
|
source_candidates,
|
||||||
@@ -17509,6 +17532,45 @@ fn api_proposal_intent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bounded_memory_body_page(
|
||||||
|
lines: &[&str],
|
||||||
|
line_offset: usize,
|
||||||
|
byte_offset: usize,
|
||||||
|
line_limit: usize,
|
||||||
|
byte_limit: usize,
|
||||||
|
) -> (String, Option<(usize, usize)>) {
|
||||||
|
let line_end = line_offset.saturating_add(line_limit).min(lines.len());
|
||||||
|
let mut line_index = line_offset;
|
||||||
|
let mut within_line = byte_offset;
|
||||||
|
let mut body = String::new();
|
||||||
|
while line_index < line_end {
|
||||||
|
let line = lines[line_index];
|
||||||
|
let remainder = &line[within_line..];
|
||||||
|
let available = byte_limit.saturating_sub(body.len());
|
||||||
|
if remainder.len() <= available {
|
||||||
|
body.push_str(remainder);
|
||||||
|
line_index += 1;
|
||||||
|
within_line = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut take = available.min(remainder.len());
|
||||||
|
while take > 0 && !remainder.is_char_boundary(take) {
|
||||||
|
take -= 1;
|
||||||
|
}
|
||||||
|
if take == 0 {
|
||||||
|
return (body, Some((line_index, within_line)));
|
||||||
|
}
|
||||||
|
body.push_str(&remainder[..take]);
|
||||||
|
within_line += take;
|
||||||
|
return (body, Some((line_index, within_line)));
|
||||||
|
}
|
||||||
|
if line_index < lines.len() {
|
||||||
|
(body, Some((line_index, 0)))
|
||||||
|
} else {
|
||||||
|
(body, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn memory_body_lines(body: &str) -> Vec<&str> {
|
fn memory_body_lines(body: &str) -> Vec<&str> {
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -17520,31 +17582,15 @@ fn memory_body_lines(body: &str) -> Vec<&str> {
|
|||||||
fn bound_subjektiv_nested_evidence(
|
fn bound_subjektiv_nested_evidence(
|
||||||
candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate],
|
candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate],
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
loop {
|
|
||||||
let serialized = serde_json::to_vec(candidates)
|
let serialized = serde_json::to_vec(candidates)
|
||||||
.map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?;
|
.map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?;
|
||||||
if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES {
|
if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES {
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
} else {
|
||||||
let mut removed = false;
|
Err(Error::Store(
|
||||||
for candidate in candidates.iter_mut().rev() {
|
"subjektiv evidence page exceeds its enforced response byte budget".into(),
|
||||||
if candidate.source_refs.pop().is_some() {
|
|
||||||
candidate.source_refs_truncated = true;
|
|
||||||
removed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if candidate.evidence.pop().is_some() {
|
|
||||||
candidate.evidence_truncated = true;
|
|
||||||
removed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !removed {
|
|
||||||
return Err(Error::Store(
|
|
||||||
"subjektiv evidence metadata exceeds its response byte budget".into(),
|
|
||||||
)
|
)
|
||||||
.into());
|
.into())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17603,6 +17649,16 @@ fn bounded_subjektiv_evidence_origin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String {
|
fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String {
|
||||||
|
let value = value
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_control() || matches!(character, '"' | '\\') {
|
||||||
|
' '
|
||||||
|
} else {
|
||||||
|
character
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
if value.len() <= max_bytes {
|
if value.len() <= max_bytes {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -35402,6 +35458,7 @@ mod tests {
|
|||||||
memory_id: first.id.clone(),
|
memory_id: first.id.clone(),
|
||||||
revision: None,
|
revision: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
|
byte_offset: None,
|
||||||
limit: Some(1),
|
limit: Some(1),
|
||||||
evidence_cursor: None,
|
evidence_cursor: None,
|
||||||
},
|
},
|
||||||
@@ -35430,6 +35487,7 @@ mod tests {
|
|||||||
memory_id: first.id.clone(),
|
memory_id: first.id.clone(),
|
||||||
revision: None,
|
revision: None,
|
||||||
offset: initial_read.body_next_offset,
|
offset: initial_read.body_next_offset,
|
||||||
|
byte_offset: initial_read.body_next_byte_offset,
|
||||||
limit: Some(1),
|
limit: Some(1),
|
||||||
evidence_cursor: None,
|
evidence_cursor: None,
|
||||||
},
|
},
|
||||||
@@ -35444,6 +35502,7 @@ mod tests {
|
|||||||
memory_id: first.id.clone(),
|
memory_id: first.id.clone(),
|
||||||
revision: Some(1),
|
revision: Some(1),
|
||||||
offset: Some(1),
|
offset: Some(1),
|
||||||
|
byte_offset: None,
|
||||||
limit: Some(1),
|
limit: Some(1),
|
||||||
evidence_cursor: None,
|
evidence_cursor: None,
|
||||||
},
|
},
|
||||||
@@ -35455,7 +35514,55 @@ mod tests {
|
|||||||
assert_eq!(read.revision, 1);
|
assert_eq!(read.revision, 1);
|
||||||
assert_eq!(read.current_revision, 2);
|
assert_eq!(read.current_revision, 2);
|
||||||
|
|
||||||
let nested_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 5;
|
let oversized_body = "界".repeat(7_000);
|
||||||
|
let oversized_memory = store
|
||||||
|
.create_memory(
|
||||||
|
&subject.id,
|
||||||
|
crate::subjektiv::MemoryDraft::active(
|
||||||
|
memory::extract::CandidateKind::Lesson,
|
||||||
|
"Page oversized lines by byte",
|
||||||
|
oversized_body.clone(),
|
||||||
|
"Keeps tool JSON intact",
|
||||||
|
"oversized line regression",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let oversized_first = subjektiv_memory_read(
|
||||||
|
&store,
|
||||||
|
&subject.id,
|
||||||
|
server_api::SubjektivMemoryReadRequest {
|
||||||
|
memory_id: oversized_memory.id.clone(),
|
||||||
|
revision: None,
|
||||||
|
offset: None,
|
||||||
|
byte_offset: None,
|
||||||
|
limit: Some(1),
|
||||||
|
evidence_cursor: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(oversized_first.body_md.len() <= SUBJEKTIV_BODY_MAX_BYTES);
|
||||||
|
assert!(oversized_first.body_truncated);
|
||||||
|
assert_eq!(oversized_first.body_next_offset, Some(0));
|
||||||
|
let oversized_second = subjektiv_memory_read(
|
||||||
|
&store,
|
||||||
|
&subject.id,
|
||||||
|
server_api::SubjektivMemoryReadRequest {
|
||||||
|
memory_id: oversized_memory.id,
|
||||||
|
revision: Some(oversized_first.revision),
|
||||||
|
offset: oversized_first.body_next_offset,
|
||||||
|
byte_offset: oversized_first.body_next_byte_offset,
|
||||||
|
limit: Some(1),
|
||||||
|
evidence_cursor: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!oversized_second.body_truncated);
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}{}", oversized_first.body_md, oversized_second.body_md),
|
||||||
|
oversized_body
|
||||||
|
);
|
||||||
|
|
||||||
|
let nested_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT;
|
||||||
let nested_evidence = (0..nested_count)
|
let nested_evidence = (0..nested_count)
|
||||||
.map(|index| memory::extract::StagingEvidence {
|
.map(|index| memory::extract::StagingEvidence {
|
||||||
id: format!("nested-{index}-{}", "i".repeat(220)),
|
id: format!("nested-{index}-{}", "i".repeat(220)),
|
||||||
@@ -35506,6 +35613,43 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let mut rejected_evidence = nested_evidence.clone();
|
||||||
|
let mut extra_evidence = rejected_evidence[0].clone();
|
||||||
|
extra_evidence.id = "nested-over-limit".to_string();
|
||||||
|
rejected_evidence.push(extra_evidence);
|
||||||
|
let mut rejected_refs = nested_refs.clone();
|
||||||
|
let mut extra_ref = rejected_refs[0].clone();
|
||||||
|
extra_ref.evidence_id = Some("nested-over-limit".to_string());
|
||||||
|
rejected_refs.push(extra_ref);
|
||||||
|
let rejected_candidate = memory::extract::StagingRecord::from_candidate(
|
||||||
|
"candidate-over-anchor-limit",
|
||||||
|
"over-anchor-limit-dedup",
|
||||||
|
memory::schema::SourceRef {
|
||||||
|
segment_id: "segment-nested".to_string(),
|
||||||
|
range: [0, nested_count as u64],
|
||||||
|
},
|
||||||
|
memory::extract::ExtractedCandidate {
|
||||||
|
kind: memory::extract::CandidateKind::Lesson,
|
||||||
|
claim: "Reject unpageable provenance".to_string(),
|
||||||
|
why_useful: "Every staging path shares the read bound".to_string(),
|
||||||
|
staleness: None,
|
||||||
|
evidence_ids: rejected_evidence
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.id.clone())
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
rejected_evidence,
|
||||||
|
rejected_refs,
|
||||||
|
);
|
||||||
|
let rejected = store.stage_candidate(crate::subjektiv::SubjectStagingRecord::attach(
|
||||||
|
&subject.id,
|
||||||
|
rejected_candidate,
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
rejected,
|
||||||
|
Err(crate::subjektiv::SubjektivError::InvalidRecord(message))
|
||||||
|
if message.contains("limited to")
|
||||||
|
));
|
||||||
let nested_candidate = memory::extract::StagingRecord::from_candidate(
|
let nested_candidate = memory::extract::StagingRecord::from_candidate(
|
||||||
"candidate-nested",
|
"candidate-nested",
|
||||||
"nested-dedup",
|
"nested-dedup",
|
||||||
@@ -35551,6 +35695,7 @@ mod tests {
|
|||||||
memory_id: nested_memory.id,
|
memory_id: nested_memory.id,
|
||||||
revision: Some(1),
|
revision: Some(1),
|
||||||
offset: None,
|
offset: None,
|
||||||
|
byte_offset: None,
|
||||||
limit: None,
|
limit: None,
|
||||||
evidence_cursor: None,
|
evidence_cursor: None,
|
||||||
},
|
},
|
||||||
@@ -35560,11 +35705,10 @@ mod tests {
|
|||||||
let nested = &nested_read.source_candidates[0];
|
let nested = &nested_read.source_candidates[0];
|
||||||
assert_eq!(nested.evidence.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
|
assert_eq!(nested.evidence.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
|
||||||
assert_eq!(nested.evidence_total, nested_count);
|
assert_eq!(nested.evidence_total, nested_count);
|
||||||
assert!(nested.evidence_truncated);
|
assert!(!nested.evidence_truncated);
|
||||||
assert!(!nested.source_refs.is_empty());
|
assert_eq!(nested.source_refs.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
|
||||||
assert!(nested.source_refs.len() <= SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
|
|
||||||
assert_eq!(nested.source_refs_total, nested_count);
|
assert_eq!(nested.source_refs_total, nested_count);
|
||||||
assert!(nested.source_refs_truncated);
|
assert!(!nested.source_refs_truncated);
|
||||||
assert!(nested.evidence.iter().all(|item| {
|
assert!(nested.evidence.iter().all(|item| {
|
||||||
item.excerpt
|
item.excerpt
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use crate::feature_storage::{
|
|||||||
|
|
||||||
pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
||||||
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
||||||
|
pub const MAX_STAGING_ANCHORS: usize = 10;
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, SubjektivError>;
|
pub type Result<T> = std::result::Result<T, SubjektivError>;
|
||||||
|
|
||||||
@@ -1898,6 +1899,12 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) ->
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS
|
||||||
|
{
|
||||||
|
return Err(SubjektivError::InvalidRecord(format!(
|
||||||
|
"staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let mut evidence_ids = HashSet::new();
|
let mut evidence_ids = HashSet::new();
|
||||||
for evidence in &record.evidence {
|
for evidence in &record.evidence {
|
||||||
validate_label("evidence id", &evidence.id)?;
|
validate_label("evidence id", &evidence.id)?;
|
||||||
|
|||||||
@@ -184,15 +184,18 @@ page members.
|
|||||||
|
|
||||||
Read accepts a positive exact revision or resolves the current revision once. A
|
Read accepts a positive exact revision or resolves the current revision once. A
|
||||||
missing historical revision never falls back to current. Markdown pagination is
|
missing historical revision never falls back to current. Markdown pagination is
|
||||||
line-based (default 200, maximum 1000) and reports `body_truncated` plus
|
line-based (default 200, maximum 1000) with an additional 16 KiB UTF-8 response
|
||||||
`body_next_offset`; every nonzero continuation offset must also supply the exact
|
budget. It reports `body_truncated`, `body_next_offset`, and
|
||||||
|
`body_next_byte_offset`; an oversized single line resumes from the returned byte
|
||||||
|
boundary within that same line. Every continuation offset must also supply the exact
|
||||||
revision returned by the first page, so a current-revision change cannot mix body
|
revision returned by the first page, so a current-revision change cannot mix body
|
||||||
versions. Provenance uses a separate immutable-revision-bound cursor and returns at
|
versions. Provenance uses a separate immutable-revision-bound cursor and returns one
|
||||||
most 20 candidate/derivation references per page. Expanded candidates share one
|
candidate/derivation reference per page. A staged candidate contains at most 10
|
||||||
page-wide budget of 20 evidence records and 20 source references, plus a hard 32 KiB
|
evidence records and 10 source references, all returned together under a hard 40 KiB
|
||||||
serialized budget for the expanded candidate array; displayed anchor text is capped
|
serialized budget for the expanded candidate array; displayed anchor text is capped
|
||||||
at 64 UTF-8 bytes and total/truncation metadata reports omissions.
|
at 64 UTF-8 bytes. Every staging path rejects larger anchor sets, so accepted
|
||||||
Explicit staging rejects larger anchor sets. Candidate evidence is the bounded host-resolved anchor saved in staging; raw Session bodies are not copied
|
provenance is never made unreachable. Candidate evidence is the bounded
|
||||||
|
host-resolved anchor saved in staging; raw Session bodies are not copied
|
||||||
into Memory responses. Resolved, retracted, and historical revisions remain
|
into Memory responses. Resolved, retracted, and historical revisions remain
|
||||||
addressable by ID.
|
addressable by ID.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user